Skip to content

Releases: classgraph/classgraph

ClassGraph 4.8.196

Choose a tag to compare

@lukehutch lukehutch released this 23 Sep 10:38

ClassGraph 5.0.0 is coming shortly, and requires JDK 17 or newer. 4.8.196 is a bugfix release on the 4.x maintenance branch, and continues the file-by-file audit that produced 4.8.190 through 4.8.195. As before, most of the bugs listed here were found by Claude through careful code analysis, and were fixed on the v5 branch and backported to v4.

Mocking the list classes with mockk works again (#945)

Since 4.8.185, mockk failed to mock ResourceList, ClassInfoList and the other list classes, with "class redefinition failed: attempted to add a method". These classes extend a package-private class, and releases since 4.8.185 were built with JDK 8, whose javac does not emit public bridge methods in the public subclass for add(T), add(int, T), remove(int) and set(int, T). The list classes now declare these methods themselves, so they are present whichever JDK builds the release.

Bug fixes: classpath elements and jarfiles

  • The context classloader could be moved behind the application classloader. The classloaders found in the environment were sorted by descending delegation depth, so that a classloader is searched before its ancestors. That also put any classloader with more ancestors ahead of an unrelated one with fewer -- the application classloader ahead of a context classloader with no parent, for example -- although the context classloader is meant to be tried first. Each classloader is now inserted just ahead of the first of its ancestors that is already listed, which keeps descendants ahead of ancestors and otherwise keeps the preference order.

  • Class-Path and Bundle-ClassPath manifest attributes are now read with their specified syntax. A Class-Path entry is a relative URL, so its percent encoding is now decoded, as the JVM's own classloader decodes it. Before, a jarfile written as my%20lib.jar was looked for under that literal name, so a jarfile with a space in its name could not be named at all. Bundle-ClassPath paths are now trimmed, unquoted and separated from their parameters, following the OSGi header syntax. Before, . , inner.jar named inner.jar, and a quoted path or one with a parameter named nothing that exists.

  • When a zipfile has two entries with the same name, the last one is now used, as the JDK does. ClassGraph kept the first, so a scan could report a different resource, and open a different nested jarfile, than the JVM would load.

  • META-INF/versions/09/ is no longer read as multi-release version 9. The JDK looks up versioned entries only under the plain decimal version number, so ClassGraph could report a class that the JVM never loads.

  • The file of a nested jarfile is now always the outermost jarfile. It was the outer jarfile if the nested jarfile was stored, but null or a temporary file if it was deflated. ScanResult#getClasspathFiles() lists the outer jarfile once, however many jarfiles are nested within it.

  • A zipfile with an Info-ZIP Unicode path extra field of a version other than 1 could not be read. java.util.zip.ZipFile opens such a file, since the JDK does not read that field. The field is now logged and ignored.

  • A large nested jar with a long name could not be opened. A nested jar too large to hold in RAM is written to a temporary file named after its zip entry, and a long entry name made File.createTempFile fail with "File name too long". The name is now cut to its last 64 characters.

  • A jarfile URL that redirected from http to https failed with "Got response code 301", since HttpURLConnection only follows a redirect that keeps the same scheme. Redirects are now followed, up to 20 times. A redirect from https to http, or to a scheme that has not been enabled, is refused.

  • A SecurityManager that refused to let ClassGraph read a file's attributes stopped the scan of the rest of that directory. Only that file is now skipped.

  • The Quarkus classloader handler failed the whole scan when a field it reads held a null or unexpected value. Such elements are now skipped.

  • normalizePath did not collapse // in a path that did not end with a separator, so a//b stayed a//b while a//b/ became a/b.

Bug fixes: memory, file handles and temporary files

  • A file is no longer memory-mapped when it could not be unmapped again. A SecurityManager that denied access to the buffer cleaner made new ClassGraph() throw "Cannot get buffer cleaner method". Now, below JDK 22, a file is only memory-mapped when the cleaner is available, since otherwise the mapping, and on Windows the file lock, would last until the buffer was garbage collected.

  • A canceled scanAsync future leaked its ScanResult. A result that arrived after the future was canceled was never handed to anyone, so nothing closed it. It is now closed. The call stack and context classloader are still read on the calling thread, but the jarfiles are now opened when the task runs, so a task that is canceled before it starts opens nothing.

  • Temporary files no longer use deleteOnExit(). Closing the scan already deletes every temporary file, so deleteOnExit() only made the JDK hold every temporary path until the JVM exited.

  • A module reader could be left open if it was returned to its pool at the same moment the pool was being force-closed.

  • Reading a stream of declared length allocated a buffer of that whole length up front, up to the maximum buffered jar size. The first buffer is now at most 16MB, and grows as data arrives. A related method doubled its buffer whenever a read returned zero bytes, even when the buffer was not full; no scan was affected, since the streams it is given never return zero.

  • A refused unmap of a buffer view was logged as "Could not unmap ByteBuffer: java.lang.reflect.InvocationTargetException". Such a view is now refused without a log entry, and any other failure is logged with its real cause.

Bug fixes: the class graph

  • ClassInfo#isAnonymousInnerClass() returned true for named local classes. It now agrees with Class#isAnonymousClass(). getFullyQualifiedDefiningMethodName() returned <clinit> for a class declared in an instance initializer or an instance field initializer, which javac compiles into the constructors; it now returns null for these, as Class#getEnclosingMethod() does.

  • A local record, enum or interface was reported as not static.

  • A type annotation on the first parameter of the constructor of a local or anonymous class declared in a static context was dropped or attached to the wrong parameter.

  • A ConstantValue attribute on a non-static field that did not fit the field made ClassGraph drop the class or misread the rest of it. The JVM ignores such an attribute, and so does ClassGraph now.

  • TypeVariableSignature#resolve() now finds a type variable declared by an enclosing class, so a T used in an inner class resolves to the T of the outer class. A type variable declared by a method no longer equals one of the same name declared by the class.

  • ScanResult#getArrayClassInfo() added the array class to the scan result, so getClassInfo() and getAllClassesAsMap() returned it afterward. For a ScanResult read back with fromJSON(), concurrent calls could also corrupt the class map.

  • A method that names a missing class stopped the fields of its class from being cached for reflection, since the NoClassDefFoundError from getDeclaredMethods() was not caught.

Bug fixes: type signatures

  • A $ after a . in a class reference is now kept as part of the nested class name, so in Outer<TT;>.Inner$Name the suffix is Inner$Name, not the two suffixes Inner and Name. A type annotation on Inner$Name was attached to a class named Name.

  • A $ with no name after it is now kept in the name before it. For a Scala object in a generic class, Lp/Outer<TT;>.Inner$;, the class name came out as p.Outer<TT;>.Inner$.

  • With simple names, List<? extends Object> rendered as List<? extends Object> rather than List<?>, and a type parameter named T$X was cut to X.

  • ClassTypeSignature#equals() now compares the class and the throws suffix, and TypeVariableSignature#equals() compares the defining class.

Bug fixes: equality and ordering

  • AnnotationInfo#hashCode() hashed array-valued parameters by identity, so two equal annotations had different hash codes. The arrays of a type annotation whose class was not scanned are now converted to primitive or String[] arrays, as they already were for other annotations. An array value that had not yet been converted compared equal to the converted array in compareTo(), but not in equals(), and hashed differently.

  • Annotation parameter values were ordered by their string form in compareTo(), so 10 sorted before 9. They are now ordered by their natural order.

  • ModuleInfo#compareTo() was not transitive when a module had no known location.

  • asMap() on a list with a repeated name, such as a repeated annotation, mapped the name to the last item, while get(name) returned the first. Both now return the first.

Output

  • FieldInfo#toString() rendered some constants as source that does not compile: NaN and the infinities, a float with no f suffix, and a long outside the int range with no L suffix.
  • GraphViz class nodes: a parameter annotation is now followed by a space, rather than run into the parameter's type.
  • Realtime log entries are now indented by their depth, rather than all being printed at the top level.
  • A ZipException from inflating a zip entry now keeps its cause.

Documentation

Many javadoc and comment fixes, among them: getClassInfo(String) returns the ClassInfo of any class the scan came across, not only accepted ones; enableStaticFinalFieldConstantInitializerValues() also covers final instance fields compiled by javac; the Gra...

Read more

ClassGraph 4.8.195

Choose a tag to compare

@lukehutch lukehutch released this 07 Sep 21:15

ClassGraph 5.0.0 is coming shortly, and requires JDK 17 or newer. 4.8.195 is a bugfix release on the 4.x maintenance branch, and continues the file-by-file audit that produced 4.8.190 through 4.8.194. As before, most of the bugs listed here were found by Claude through careful code analysis, and were fixed on the v5 branch and backported to v4.

The themes this time are a scan that can never finish, temporary files that were left behind or misnamed, classpath entries that were silently dropped, and a set of correctness fixes in the class graph itself.

New API

  • ClassGraph#setWorkerTimeout(long, TimeUnit). Every Future#get() call in the scanner now has a timeout, so a scan that can never finish reports why instead of hanging. The two known causes are the classloading deadlock of #933, and a worker thread blocking on a filesystem or network read of a classpath element — a stalled HTTP server serving a remote jarfile, for instance. The timeout defaults to one minute, so a scan that previously blocked forever now fails with an exception naming the reason. A timeout of zero or less waits indefinitely, which is the previous behavior.

Bug fixes: scans that never return

  • The call stack and the context classloader were read on a worker thread during scanAsync(). The Scanner was constructed inside the task submitted to the ExecutorService, so the call stack that ClassGraph reads to find classloaders, and the thread context classloader it uses, were those of a pool thread rather than of the caller. They are now read on the calling thread, before anything is submitted.

  • The #933 fallback now works on Java 8, and covers a classloader as well as a static initializer. If the calling thread holds a class loading lock, loading a class on a worker thread deadlocks the scan, so the whole scan is run on the calling thread whatever number of threads was requested, and the frame holding the lock is named in the verbose log. Where StackWalker is not available, the classes declaring the stack frames are taken from the call stack that was already read, so a frame can be matched to its class without loading anything — which is what must not be done while a class loading lock is held.

Bug fixes: temporary files

  • A jarfile whose name contains --- was scanned under a truncated name. ClassGraph names the files it extracts ClassGraph--<random>---<name>, and leafName() treated --- anywhere in a path as that separator. A jarfile genuinely named x---y.jar was therefore reported as y.jar, and that truncated name is what accept and reject criteria are matched against. Worse, a --- in a path inside a jarfile falls after the end of the leafname, so the leafname came back empty and the jar matched no criterion at all and was silently skipped. The separator is now only honored in a leafname that starts with ClassGraph--, and only at the first separator after that prefix.

  • A temporary file that was still memory mapped at the end of a scan was left on disk for the life of the JVM. Below JDK 22 a mapping is dropped by freeing its address range, which cannot be done while the caller can still read a buffer of it, so a file held open by such a view stays mapped after the scan closes. Windows refuses to delete a mapped file, and the retry that closing the scan makes after asking for a garbage collection cannot help either, since the buffer the caller holds is strongly reachable. The file is now deleted when the last view of its mapping is released, rather than being left to the deleteOnExit() hook.

  • A slice or an arena that would not close was not reported. An IOException from a slice that would not close was dropped silently, so a file handle or a mapping that outlived a scan left nothing in the log to explain why a jarfile could not afterwards be deleted or overwritten on Windows; an unchecked failure was not caught at all, and abandoned the rest of the teardown — the slices still to be closed, the inflater recycler, and the temporary files. On JDK 22 and later, an arena that fails to close leaves its file mapped, and that too went unrecorded, so no collection was asked for before the temporary files were deleted.

  • Scanning an exploded module leaked an open directory for the life of the JVM. ModuleReader#list() returns a stream that walks the module's directory tree, and closing that stream is what closes the directories the walk opened. It was collected into a list and dropped without being closed.

Bug fixes: classpath elements that were dropped or duplicated

  • A nested classpath element was missed when a sibling sorted between it and its parent. findNestedClasspathElements() sorts the classpath elements and walks forward from each looking for elements nested within it, stopping at the first element that is not nested. Every character below / sorts before it, so for the base path /a/classes the sibling /a/classes-extra falls between it and /a/classes/sub: the walk stopped at the sibling, the outer element no longer masked the nested one, and the nested element's resources were reported twice.

  • A package root or nested jarfile that exists only under a version prefix could not be found. An entry stored under META-INF/versions/N/ in a multi-release jarfile is served without that prefix, and the base copy it masks is gone from the entry list, so a lookup by the stored name matched nothing: naming app.jar!/WEB-INF/classes on the classpath failed with "Path WEB-INF/classes does not exist in jarfile", and the package root of a war or Spring Boot jar whose classes are versioned was dropped.

  • Version prefixes were resolved in a jarfile that is not multi-release. A jarfile is only multi-release if its manifest carries the Multi-Release key; in one that does not, the JVM reads an entry under META-INF/versions/ from the path it is stored under. The prefix was being stripped from every entry as the central directory was read, which is before the manifest has been parsed, so a "versioned" entry masked the base entry of the same path — the scan reported the versioned entry's content under the base entry's path, and did not report the base entry at all.

  • A classpath entry whose path a URI cannot hold was dropped entirely. When stripping a package root suffix from a URI or URL entry, the resolved path — which is percent-decoded — was parsed back as a URI, and the entry was discarded when neither the bare path nor the file: spelling parsed. A path containing a space took that branch, as did any Windows path, since a backslash is illegal in a URI. It now degrades to the path string, as the File branch already did.

  • A URL that was already percent-encoded was encoded a second time. normalizeURLPath left an http:, https: or jrt: URL alone and then passed it to the encoder written for decoded file paths, so %20 became %2520, the colon before a port number became %3a, and a query string ?v=1&t=2 became %3fv%3d1%26t%3d2 — after which URI#getHost returned null and URI#getPort returned -1, matching nothing that ClassGraph actually fetches. Such a URL is now escaped by a rule that leaves an existing escape alone and encodes only what a URI cannot hold. File paths are unchanged.

Bug fixes: classloader order

  • A classloader appeared in the delegation order once per handler that could handle it. More than one ClassLoaderHandler can handle the same classloader — one that a handler recognizes by name may also extend URLClassLoader — and each is asked for the entries it knows how to read, but the classloader itself was added to the order each time.

  • Two classloaders that claim to be equal collapsed into one. findDefaultClassLoaders() collected classloaders in a LinkedHashSet, so the classes of the one that was dropped were never scanned. Separately, ClassLoaderOrder deduplicated by identity, as it must, but then kept the order in a map keyed by classloader: adding the second of two equal classloaders replaced the handlers of the first rather than appending, so one of the two dropped out of the order and its entries were never found, and the one left behind ran the other one's handlers. This is not hypothetical — TomEE makes an instance of CxfContainerClassLoader equal to the TomEEWebappClassLoader it delegates to (#515).

Bug fixes: zipfile and classfile reading

  • An MS-DOS zip timestamp was read in the default locale's calendar system. The year, month and day of an MS-DOS date are Gregorian, but the conversion built a calendar for the default locale, so under th-TH-u-ca-buddhist or ja-JP-u-ca-japanese an entry timestamped September 2020 was reported as September 1477. Only an entry with no extended timestamp extra field is affected, which is what ZipOutputStream writes when ZipEntry#setTime(long) is the only time that was set.

  • A read into a ByteBuffer that carried a limit from a previous read threw IllegalArgumentException. A read leaves the buffer's limit where it stopped, so a later read starting further into the same buffer positioned past that stale limit — not one of the exceptions InputStream.read(byte[], int, int) is allowed to throw for a valid range. Three of the four readers already opened the limit before positioning; ClassfileReader did not, so the same call sequence failed or succeeded depending on where the content was being read from. The file-channel reader reuses one ByteBuffer across array reads, so array reads through it were affected too.

  • A stream of unknown length was read into a 64MB buffer. The buffer was allocated at maxBufferedJarRAMSize even for a stream holding a few bytes, and a stream whose length hint understated it was spilled to a temporary file r...

Read more

ClassGraph 4.8.194

Choose a tag to compare

@lukehutch lukehutch released this 25 Aug 03:42

ClassGraph 5.0.0 is coming shortly, and requires JDK 17 or newer. 4.8.194 is a bugfix release on the 4.x maintenance branch, and continues the file-by-file audit of the codebase that produced 4.8.190 through 4.8.193. As before, most of the bugs listed here were found by Claude through careful code analysis, and were fixed on the v5 branch and backported to v4.

The theme this time is the order in which scan sources are searched: which classloader's copy of a class wins, where the application classpath sits in that order, and how module layers are traversed. There is also a fix for a scan that never returns.

Bug fixes: deadlock

  • A scan started from a thread holding a classloader lock never returned (#933). scan() and scan(int) submitted the Scanner to an ExecutorService and blocked the calling thread on the Future, so the first classes the scan needed were loaded on a worker thread. If the calling thread held a lock that the classloader also acquires — not unusual during a host's startup, as in Fabric/Knot — the worker blocked in ClassLoader.loadClass while the caller waited for it, and neither ever moved. Neither side of that cycle is a monitor that both threads contend for, so the JVM did not report it as a deadlock either, and jstack showed nothing wrong. The Scanner now runs on the calling thread; the ExecutorService is still used for the parallel stages of the scan, and is not used at all when numParallelTasks is 1, so scan(1) now loads every class it needs on the calling thread and cannot deadlock that way. scanAsync() is unchanged, since running on a pool thread is its purpose.

Bug fixes: classpath and classloader order

  • A classloader could be ordered behind its own ancestors. The list of classloaders found in the environment was seeded with the thread context classloader, ClassGraph's own classloader and the system classloader before the call stack was read, so an ancestor could be placed ahead of the descendant that actually called ClassGraph. Only the position of the first classloader of a delegation chain to be reached is decided by that list — once a classloader is reached, its ClassLoaderHandler decides where its ancestors' classpath elements go relative to its own — so pinning an ancestor first silently converted parent-last delegation, the default for Tomcat's WebappClassLoader and for Spring Boot DevTools' RestartClassLoader, into parent-first delegation, inverting the class masking order. The classloaders found in the environment are now sorted by descending delegation depth, which cannot place an ancestor ahead of one of its descendants, and the call stack is read innermost frame first, so the immediate caller's classloader is preferred over that of the code that called it, mirroring how Class.forName(String) resolves against its immediate caller. Classloaders added with addClassLoader() are still appended after them, as that method documents.

  • The application classpath was searched after the classloaders that delegate to it. The java.class.path entries were appended after every classloader had been visited, which inverts the masking order: a class present both on the application classpath and in a child classloader was reported from the child, whereas parent-first delegation makes the JVM load the application classloader's copy. Those entries are now contributed by the handler for the application classloader, so they land at the position the application classloader takes in the delegation order, like any other classloader's entries. This also makes ignoreParentClassLoaders() behave as its documentation says: it now leaves out only the entries that a parent classloader declares, instead of also dropping the application classloader's own entries when the application classloader is itself one of the classloaders being searched.

  • A module layer reachable from more than one named layer was listed more than once, and the resulting order then depended on which layers the caller happened to name rather than on the layer DAG alone. Naming a parent layer and its child, in either order, gave the same result — the child first — so asking for the parent's modules to be searched first had no effect. The visited set is now shared across all top-level layers, so a layer named directly keeps the position its own name gives it, and is reached indirectly through ModuleLayer#parents() only if the caller did not name it. The javadoc now also states why a layer's own modules come before its parent layers': the classloader a layer creates is a jdk.internal.loader.Loader, whose loadClass checks this layer's own modules before the parent layers' and before its parent classloader — the reverse of the classloader axis, and observable, since a child layer may define a module with the same name as one in a parent layer and the child's copy then wins.

  • Six ClassLoaderHandlers were missing classpath entries that their classloaders expose. An audit of the source of every supported classloader turned these up; nothing that was already read has been removed, since a field or method absent from the current source may still be present in an older version. Uno-JAR also accepts extra entries in the uno-jar.class.path system property, separated by |. A JBoss ResourceLoader that wraps another one, such as a FilteredResourceLoader, exposes only the location of the loader it delegates to. An Equinox BundleFileWrapper installed by a framework extension copies only the base file of the bundle file it wraps, so without following its bundleFile field the sub-path within the bundle is lost. A Felix Content with no file of its own delegates to the Content in its m_content field. The bundle file of an older Equinox classpath entry can be a nested directory or a wrapper chain, exactly as in newer versions, and the bundle's fragments have classpath entries of their own. A WebSphere Liberty AppClassLoader delegates to the classloaders of its configured libraries, split by precedence into beforeAppDelegateLoaders and afterAppDelegateLoaders, and a ThreadContextClassLoader searches the classloaders in followOnClassLoaders after its parent.

Bug fixes: resource paths

  • A package root within a jarfile was separated from a resource path with !/ rather than /. A classpath entry can name a package root within a jarfile, e.g. app.jar!/BOOT-INF, and Resource#getURI() appended !/ between the URI of the classpath element and the path of the resource within it, giving app.jar!/BOOT-INF!/classes/hello/HelloController.class — a URL with two !/ separators but only one archive in it, which does not resolve. A package root is a directory within the jarfile, not a jarfile nested inside it, so a resource beneath it is separated from it by /. The default automatic package root prefixes masked this for the paths they cover.

Bug fixes: classfile parsing

  • A class using the JVMS-specified encoding of a Class-valued annotation element vanished from scan results. JVMS 4.7.16.1 specifies that the class_info_index of a tag c annotation element value refers to a CONSTANT_Class entry, but javac writes the type descriptor directly as a CONSTANT_Utf8 entry instead, which is what AnnotationClassRef expects. With a classfile that follows the spec to the letter, the binary class name failed to parse as a descriptor and the whole class was silently dropped. CONSTANT_Class references are now converted to type descriptors, and UTF8 constants are passed through unchanged.

  • Mixing RUNTIME- and CLASS-retention type-use annotations on one declaration dropped all the RUNTIME ones. javac emits both the RuntimeVisibleTypeAnnotations and RuntimeInvisibleTypeAnnotations attributes on the same target in that case, and the field, method and class attribute readers each overwrote the decorators of the first attribute with those of the second. The two lists are now merged.

  • A constant declared by an implemented interface could not be found through an implementing class. The reflection driver's member cache walked the superclass chain caching declared methods and fields, then walked the interface graph caching only declared methods. Both kinds are now cached at both traversal sites, and methods and fields are read in separate try blocks, so a class whose fields cannot be read still has its methods cached, and vice versa.

Behaviour changes

  • ClassInfo#toString() now names only the class in an extends or implements clause, as Java source does. It previously rendered a superclass or superinterface with its own modifiers, class type keyword and extends/implements clauses, producing output that is not a Java declaration:

    public static class Child extends public abstract static Parent extends java.lang.Exception implements public abstract static Marker implements public abstract static Tag
    

    The named class's modifiers, class type, type parameters, record parameters and supertypes all belong to its own declaration. This changes toString() output for any class whose supertypes have supertypes or modifiers of their own.

Dependencies and documentation

  • Narcissus updated to 1.0.13, which adds a native library for Linux on arm64, so ClassGraph can read the classpath through Narcissus on that platform too.

  • The README and the CIRCUMVENT_ENCAPSULATION javadoc listed the wrong set of platforms Narcissus supports: there have been no 32-bit x86 builds for a long time, and Linux arm64 and macOS arm64 were missing.

ClassGraph 4.8.193

Choose a tag to compare

@lukehutch lukehutch released this 20 Aug 23:27

ClassGraph 5.0.0 is coming shortly, and requires JDK 17 or newer. 4.8.193 is a bugfix release on the 4.x maintenance branch, and continues the file-by-file audit of the codebase that produced 4.8.190 through 4.8.192. As before, the bugs listed here were found by Claude through careful code analysis, and were fixed on the v5 branch and backported to v4.

The largest group this time came from an audit of what a scan holds open and when it lets go of it: memory mappings, file handles, streams and pooled objects.

Bug fixes: memory mapping

  • Closing a ScanResult could unmap a file while another thread was still reading it, killing the JVM. FileSlice#close() unmapped the file the slice was reading, but aliases of that mapping escape three ways: a RandomAccessByteBufferReader keeps a duplicate for the life of the reader, FileSlice#read() hands a slice of the mapping to the caller, and a sub-slice duplicated it. Below JDK 22 the only way to unmap on demand is Unsafe::invokeCleaner, which frees the address range immediately and unconditionally, so a thread that read one byte afterwards took a SIGSEGV. A sub-slice now reads through the toplevel slice instead of duplicating the mapping, a reader taken before the close is given the slice's closed flag to check before each read, and a file is unmapped only once the toplevel slice has closed and every view of the mapping that a caller could still read has been released.

  • A memory-mapped jarfile stayed mapped after the scan that mapped it was closed. Below JDK 22 there is no arena, so closing a ScanResult merely dropped the last reference to each mapping and left the unmapping to the garbage collector — which might do it minutes later, or never. Windows refuses to delete, rename or overwrite a file while it is mapped, so a scanned jar stayed locked long after the ScanResult was closed, an extracted temporary file was left behind, and a scanned directory could not be deleted. Mappings are now unmapped explicitly when the scan closes, on every JDK: with the arena on JDK 22 and later, with Unsafe::invokeCleaner on JDK 9 to 21, and with sun.misc.Cleaner below JDK 9. The garbage collector is now only the fallback, for a mapping that could not be unmapped explicitly.

  • A closed resource stream went on holding its reader, and a reader of a memory-mapped file holds a view of the mapping, so the file stayed mapped for as long as anything still referred to the stream. The stream now drops its reader as it closes.

  • enableMemoryMapping() still maps on every JDK. The obvious way to close the crash above would have been to stop mapping below JDK 22, where the unmapping cannot be made safe by the arena — but that would have given up the mapping speedup (16-38% faster than the RandomAccessFile API on Windows) on every release but the newest. Ordering the unmapping behind the last live view of the mapping makes it safe without that, so mapping still happens wherever it is asked for.

Bug fixes: resource and scan lifecycle

  • A failed scan leaked every file handle, memory mapping and module reader it had opened. On the failure path the NestedJarHandler was closed only when there was no FailureHandler, or when the FailureHandler itself threw; a FailureHandler that returned normally fell through to a step that deletes temporary files but closes nothing. Three sibling leaks were fixed with it: the Scanner constructor released the handler only on InterruptedException, so a throwing classpath element filter leaked the whole handler with no way for the caller to reach it; a ScanResultProcessor that threw an Error — which is what a failing assertion inside one throws — skipped both close() calls, and nothing else can close that ScanResult; and a ScanResult that was garbage collected without being closed left a dead WeakReference husk in a static set forever, so the set grew without limit in a program that scans repeatedly without closing.

  • A resource that could not be opened was left marked as open. Resource#checkCanOpen set the open flag before checking whether the ScanResult had been closed, so every later attempt to open that resource reported "Resource is already open" instead of the real reason it could not be opened.

  • Handing a pooled object back after a ScanResult was closed either threw or leaked. Closing a ScanResult force-closes the inflater and module-reader recyclers, but a recycler kept no memory of that: it drained the pool and left recycle() free to add instances back in. An instance handed back afterwards was either rejected outright — with "Tried to recycle an instance that was not in use" thrown out of the close() that was handing it back — or, if it had been acquired after the force-close, pooled into a recycler that nothing would ever drain again, leaking the Inflater or ModuleReader it held. Closing a resource stream after closing the ScanResult reaches the first case. A force-close is now terminal.

Bug fixes: zipfile reading

  • A zipfile comment containing the bytes PK\x05\x06 made the jarfile unreadable. The end of central directory record is found by scanning back from the end of the file for that signature, and the record's comment length field was never checked against the bytes actually present, so the first plausible-looking signature found was accepted as the real record. The comment length now has to account for the remaining bytes exactly, and the scan continues to an earlier candidate if it does not. (Zipfiles do exist with data appended after the comment, or with the wrong comment length recorded, so if no candidate satisfies the check the last signature found is still used, as before.)

  • A jarfile whose entry names had been lower-cased was read as having no manifest at all, which silently dropped its Class-Path, its Bundle-ClassPath and everything else the manifest says. java.util.zip.ZipFile finds that manifest, because it matches META-INF/ and MANIFEST.MF a character at a time with the case bit masked off. The manifest is now looked for under its canonical name first, and only then under a name that differs from it in case alone.

  • A classfile named Foo.CLASS aborted the entire scan. JarUtils.classfilePathToClassName threw for a path whose extension was not exactly .class, even though such a file declares the same class at the same position in the directory tree and can be read like any other. Every "is this a classfile" test now goes through one case-insensitive predicate. module-info.class and package-info.class stay case-sensitive, since the JLS and the JPMS mandate those exact names.

Bug fixes: nested jar paths and classpath URLs

  • The nested jar separator is now spelled !/ everywhere, as the jar: URL scheme requires. sun.net.www.protocol.jar.Handler looks for the first ! that is immediately followed by /, and java.net.JarURLConnection rejects a URL whose ! is not followed by / while the URL is being constructed. ClassGraph found the outermost separator by testing the filesystem and then took every later ! to be a separator too — but ! is a legal character in a file or entry name, so outer.jar!/dir!name was split inside dir!name, and normalizing outer.jar!/dir!name/x.txt produced a URL naming an entry that does not exist. Which ! characters separate is now decided by how the outermost one is spelled, and JarUtils#toJarUrlSeparators is the single place a looser path is rewritten into the scheme's form.

  • A relative path that looks like a URL was misread as one. : is a legal filename character on every platform ClassGraph supports except Windows, and a relative path need not begin with /, so foo:bar is spelled exactly like a URL whose scheme is foo. The syntactic rule is now applied only after the filesystem has been asked, so cgtest:relpath/dir!name/y.jar is no longer split at the ! while reldir/dir!name/y.jar — the same path but for the colon — is correctly left alone.

  • A URL scheme containing a digit was not recognized when ordering the classpath, even though RFC 3986 allows digits after the first character.

  • The same resource reached under two spellings was scanned twice. Two of these: a scheme that is merely kept rather than recognized by name, such as S3://bucket/key, was passed through in whatever case it was written in, rather than lowercased to its canonical form; and the key that decides whether two resources are the same file canonicalized only the directory the file is in, keeping the name it was reached by, so a jar reached through a symlink was a different file from the jar itself, and on a case-folding filesystem a jar named with a different case was a different file too — which on macOS and Windows the module path and the classpath routinely produce.

  • 18 of the 20 ClassLoaderHandler registry entries had null cached for their package root prefixes. The registry entries are built by a static initializer that runs before the prefix constants declared further down the same class are assigned, and the entry read the prefixes in its constructor. The entry now forwards to the handler rather than caching, so it reads the constants after they are assigned. Fifteen of the affected handlers were unaffected in practice, since null is replaced by the defaults; the other three returned "no package root prefixes", and none of the three justified suppressing package roots — the JPMS handler in particular contributes the jarfiles a Java agent appended to the system classloader's search, which are not modules, so a Spring Boot jar appended that way needs its package root stripped like any other.

  • A failure to parse or convert a jar URL discarded the underlying cause, so the exception said what had failed but not why.

Bug fixes: interruption handling

  • Interrupting a scan could leave it running to completion, retu...
Read more

ClassGraph 4.8.192

Choose a tag to compare

@lukehutch lukehutch released this 13 Aug 21:52

ClassGraph 5.0.0 is coming shortly, and requires JDK 17 or newer. 4.8.192 is a bugfix release on the 4.x maintenance branch, and continues the file-by-file audit of the codebase that produced 4.8.190 and 4.8.191. As before, the bugs listed here were found by Claude through careful code analysis, and were fixed on the v5 branch and backported to v4.

Bug fixes: zipfile reading

  • A zip entry name was decoded as UTF-8 whether or not the entry said it was UTF-8. The zip specification says a name is encoded in IBM Code Page 437 unless bit 11 of the entry's general purpose bit flag is set, and Windows Explorer and Info-ZIP are among the tools that follow it. So the name of an entry written by one of those tools came out as garbage as soon as it contained a byte outside ASCII, and the class or resource could not be found under the name it was stored with. The flag is now read before the name, and the name decoded as CP437 or UTF-8 accordingly. Malformed UTF-8 no longer throws either: an undecodable byte is replaced rather than the whole entry being lost, which is what java.util.zip does.

Bug fixes: resources

  • Resource#read() returned a buffer covering the entire jarfile rather than the entry, when memory mapping was in use. The buffer's position was the entry's offset, so a sequential read returned the right bytes, but capacity() was the size of the whole jarfile, an absolute read such as get(0) read from the start of the jarfile, and clear() widened the buffer back out over the zip headers and every other entry. The mapping is now narrowed to the entry, so the buffer starts at position zero and cannot be widened again.

  • A module resource's content was aliased rather than copied. The buffer belongs to the ModuleReader, which reclaims it when the resource is closed, so the content could change underneath a caller that kept the buffer.

Bug fixes: classpath order

  • A classpath element named from more than one place got a single position, when it does not have one. The index of a classpath element within the Class-Path manifest entry (or lib directory) of the element that named it was stored on the child element rather than on the edge from the parent, and merged to the smallest index across every reference to that child. The same jarfile can be named by the Class-Path entries of two different jarfiles at a different position within each of them, so there is no one position it occupies.

    Two consequences, both of which decide which of two copies of a class masks the other: an element that was also listed on the top-level classpath sorted ahead of all of its parent's other entries, and an element named by two parents took the lower of its two indices under both, so it could tie with a sibling and fall back on whichever order the parallel scan tasks happened to finish in. Fixes #810.

ClassGraph 4.8.191

Choose a tag to compare

@lukehutch lukehutch released this 13 Aug 09:09

ClassGraph 5.0.0 is coming shortly, and requires JDK 17 or newer. 4.8.191 is a bugfix release on the 4.x maintenance branch, and continues the file-by-file audit of the codebase that produced 4.8.190. As before, the bugs listed here were found by Claude through careful code analysis, and were fixed on the v5 branch and backported to v4.

Spring Boot 3.2 and later

  • A Spring Boot 3.2+ or 4.x executable jar is now scanned. Since 3.2, the Spring Boot launcher addresses an entry inside an executable jar or war using its own nested: URL protocol, which separates the path of the outer archive from the name of the entry within it with /! rather than the standard !/ — for example jar:nested:/path/to/app.jar/!BOOT-INF/lib/dependency.jar!/. Those are the URLs its classloader hands out, so ClassGraph resolved every one of them as a relative file path and found nothing at all: a scan of a Spring Boot 3.2+ executable jar returned zero classes. Such URLs are now converted to the equivalent jar: URL, and a classpath string containing one is no longer split at the nested: scheme's colon.

Behavior changes

  • The classpath order no longer depends on the order a filesystem happens to return directory entries in. The jars of an automatic lib directory of a directory classpath element (lib/, BOOT-INF/lib/, WEB-INF/lib/ and the rest) and the JRE's own lib and ext jars are now sorted by filename, so the same JRE and the same application produce the same classpath order on every machine, and which of two jars containing the same class masks the other no longer varies from run to run. Each lib directory is sorted on its own, so the order of the lib directory prefixes still decides which one's jars come first. A dir/* wildcard classpath entry is deliberately left unsorted, because that reproduces the java launcher's own expansion order.

  • ScanResult#getPackageInfo() and #getModuleInfo() return sorted lists. They previously returned hashmap iteration order, which varies between runs.

Bug fixes: zipfile reading

  • Every zipfile larger than 4 GB was misread. The Zip64 extended information extra field was read as though all of its values are always present, when in fact it carries only the values that overflowed their 32-bit fields, in a fixed order. The fields present are now determined by which of the 32-bit values are set to the overflow marker.

  • A zipfile whose comment is longer than 65514 bytes could not be opened. The search for the end-of-central-directory record stopped short of the largest comment the format permits.

  • A zip entry whose compressed size runs past the end of the archive threw IllegalArgumentException out of the zipfile reader, rather than being reported as the malformed archive it is. An entry whose Zip64 compressed size overflowed a 64-bit addition was silently read past its end. Both now throw IOException.

  • A central directory record declaring an extra field area that extends past the end of the central directory made the whole zipfile unopenable. An empty Info-ZIP Unicode path extra field also made ClassGraph read one byte outside the field. Records are now read strictly within their own bounds.

  • A zip entry's Unix timestamp extra field was read as an 8-byte time rather than the 4-byte time the format specifies, so entries written by zip(1), Info-ZIP or Gradle silently fell back to their coarse local-time MS-DOS timestamps.

  • A stream that returned zero from two consecutive reads made ClassGraph read a jarfile as empty. A zero-length read is not end of stream.

  • A classfile attribute close to 2 GB wrapped the read position negative in ClassfileReader.skip(int).

  • The log message "Skipping zip entry with invalid extra field size" claimed an entry was skipped when it was still read.

Bug fixes: resources and file handles

  • A jarfile reached through a jar: URL left the outer jarfile open for the life of the JVM.

  • A Resource whose open failed with an IOException was left marked as open.

  • A module resource that could not be read threw IllegalArgumentException out of Resource#read() and #open(), left the resource marked as open, and leaked the ModuleReader from the recycler. It now throws IOException.

Bug fixes: classpath finding

  • A Class-Path manifest entry was resolved incorrectly against a jarfile named by a URL, so jars added that way were not found.

  • An unrecognized classloader added one bogus jrt:/<module> classpath element per module of the runtime image — 21 of them on Java 26. When a classloader reports nothing about its own classpath, FallbackClassLoaderHandler asks it for resources at the root of a classpath element and strips the resource path from the URLs. Resources served only by delegation to a parent are skipped, but a classloader with a null parent delegates to the bootstrap classloader, whose resources were never enumerated — so its copy of module-info.class was counted once per runtime module. The bootstrap classloader's resources are now enumerated through a classloader that has none of its own and no parent.

  • classpathContentsLastModifiedTime() never changed, however the files changed. It is documented to check the files' current timestamps — the only thing it is useful for — but returned the timestamps recorded during the scan. It now reads the current timestamp of each file, as its companion classpathContentsModifiedSinceScan() always has.

  • classpathContentsModifiedSinceScan() reported any directory classpath element as modified as soon as it was scanned, on Java 8. Timestamps of files in a directory classpath element were recorded through the file attributes, while every other timestamp and the comparison itself go through File#lastModified(). On Java 8 the file attributes are truncated to whole seconds, so the two disagreed unless a file's timestamp happened to land on a whole second. Timestamps are now recorded the same way they are compared.

Bug fixes: scanning

  • A scan run on an ExecutorService with no free thread hung forever. A work queue submits one worker per parallel task and also processes work on the calling thread, so the work still gets done when no worker can start — but the barrier at the end waited for every worker it had submitted, including ones the executor had never had a free thread to start, and those cannot start until the waiting thread returns. The barrier now waits only for workers that really started, and claims the rest so they return without running the work loop if started later.

  • LogNode.logInRealtime is now volatile: it is written from the caller's thread and read by the LogNode constructor on the scan threads.

ClassGraph 4.8.190

Choose a tag to compare

@lukehutch lukehutch released this 11 Aug 23:56

Major cleanup release before 5.0.0, which requires JDK 17 or newer and splits the library into separate modules. A porting guide will accompany that release.

4.8.190 is the result of a file-by-file audit of the whole codebase, carried out while porting it to 5.x. The bugs listed below were found by Claude through careful code analysis, and were backported from the v5 branch to v4.

Behavior changes

These change results that some code may depend on, so they are listed first.

  • Each classloader is now placed in the classpath order where its handler puts it, rather than always ahead of the classloaders it delegates to. Every classloader handler already declared its container's delegation order — the Tomcat handler even reads Tomcat's own delegate flag — but none of it had any effect, because the classloader was added to the order before its handler ran. For a parent-first classloader the parent's classpath elements now come first, which is where the JVM resolves classes from, and which is what class masking depends on. This changes the reported classpath order, and which copy of a class defined in more than one classpath element wins, in any setup where classloaders are nested. It does not change the order for the JDK's own classloaders in a normal application.

  • rejectClasspathElementsContainingResourcePath() rejects the whole classpath element again, not just the matching resource. The element-level skip was lost in 4.8.150; the replacement matched the classpath element's own absolute path against the reject criteria, which can never match, since those criteria are matched against relative resource paths.

  • Classpath elements reachable both as a module and as a classpath entry are now deduplicated, by file identity, so a symlinked path is recognized as the same file. Previously such a jar or directory was listed twice by getClasspathURIs(), getClasspathURLs(), getClasspathFiles() and getClasspath(), and was opened and scanned twice. Modules are ordered first, matching the JVM, which loads the classes from the module.

  • acceptClasses() and friends now match every character of a simple class name glob literally, except *. Regexp metacharacters were previously copied into the compiled pattern unchanged, so acceptClasses("com.Outer$Inner*") compiled to a pattern with an end-of-input anchor in the middle and silently matched nothing. Filesystem-style globs, as used by getResourcesMatchingWildcard(), are unchanged.

  • Once enableSystemJarsAndModules() is on, system modules obey the same accept/reject rule as every other module. Previously a reject on its own matched neither of the two cases that enabled system module scanning, so after enableSystemJarsAndModules().rejectModules("jdk.compiler") no system module was scanned at all, not even java.base. Part of #658.

  • Three queries that chain two graph traversals now filter external classes only at the end, so they no longer miss accepted classes that can only be reached by passing through an external class: getClassesImplementing() on an accepted interface missed an accepted class whose external superclass declares implements; getClassesWithAnnotation() on an accepted @Inherited annotation missed an accepted class inheriting it from an external superclass; and getClassesWithFieldAnnotation()/getClassesWithMethodAnnotation() on an accepted meta-annotation missed an accepted class whose field or method carries an external annotation. Whether external classes are reported is now decided once, by the class the query started from, as it already was for every other query.

  • Off Windows, a classpath entry of the form C:/a/b is now read as a relative path rather than as a URL with scheme C:. A single letter is no longer accepted as a URL scheme, matching JarUtils.URL_SCHEME_PATTERN, which has always required two characters. Such an entry does not exist off Windows, so it is now logged and skipped.

  • A bare mvn deploy without -Prelease no longer publishes to Maven Central, and fails with a missing distribution repository instead. central-publishing-maven-plugin moved into the release profile, because it is declared with <extensions>true</extensions> and so had to be resolved at POM-read time on every ordinary build — a cache miss plus a transient Central outage killed builds before a single test ran. Both documented deploy paths still activate that profile.

Bug fixes: zipfile and classfile reading

  • A truncated deflated zip entry never finished being read. The nowrap inflater needs a dummy byte at the end of its input, but a fresh one was supplied every time the inflater asked for more input after the raw stream was exhausted. Each dummy byte decodes as more deflate symbols, so the stream yielded an endless supply of garbage: Resource#load spun until it died with OutOfMemoryError: Required array size too large, and a discarding read loop never terminated at all. An EOFException is now thrown the second time the inflater runs out of input. mark() is also now a no-op and reset() throws IOException, as the InputStream contract requires of a stream whose markSupported() is false — both previously threw the unchecked IllegalArgumentException into user code.

  • A jarfile was unreadable if any manifest header name contained a byte above 0x7f. The case-insensitive header lookup table was indexed with the raw signed byte, so the byte was negative and threw ArrayIndexOutOfBoundsException, making the whole jar unreadable rather than just skipping the unrecognized header.

  • A short read from a FileChannel was reported as a truncated file. FileChannel#read is not required to transfer the whole requested range in one call, and a read from a network filesystem can be short, but every caller treated that as a premature EOF. The reader now keeps reading until the request is satisfied or the file ends.

  • A short read from a classfile InputStream was reported as Buffer underflow. Both streams that ClassfileReader is built over in production are channel-backed and really can transfer less than asked: a module read through ModuleReader, and a directory entry read through Files.newInputStream. A classfile could therefore fail to parse even though all of it was available.

  • Extra field parsing stopped at the Zip64 extended information field, so any extra field after it was never read — including an Info-ZIP Unicode path field, which replaces the entry name. A zero-length extra field at the very end of the extra field area was also skipped.

  • An entry name taken from an Info-ZIP Unicode path extra field was used exactly as read, while the name it replaces goes through path sanitization and a directory-entry check. A jar entry could therefore carry a path escaping its package root (pkg/../../escaped/x), an absolute path (/x), or a directory name (pkg/dir/), just by declaring it in an extra field. Confirmed by building such a jar and listing the resources ClassGraph reported for it.

  • Entries timestamped in the last five months of the year were read as the first five — September as January, and so on. The MS-DOS date month field is four bits wide, not three. Only affects entries with no Unix or extended timestamp extra field.

  • A read could run past the end of a slice. The array and byte buffer readers indexed their backing store directly, so for an in-RAM jar a read returned the surrounding bytes, and otherwise threw an unchecked exception out of a path declared to throw IOException. Zipfile read offsets come from the zipfile itself, so a corrupt zipfile can ask for a read anywhere. Reading part of a slice into a ByteBuffer also copied the whole rest of the slice, overflowing the destination.

  • RandomAccessFileChannelReader let ByteBuffer#limit throw IllegalArgumentException when the destination buffer had less room left than the caller asked for; the other three readers clamp the read instead.

  • InputStream#read() on a slice-backed resource returned the number of bytes read rather than the byte value, so reading a resource one byte at a time yielded a stream of 0x01 bytes. This affected every resource whose bytes are not deflated: a file in a directory classpath element, and a stored zip entry. close() was also not idempotent, so closing a stale stream closed a stream that had since been opened on the same Resource.

  • A slice left open by a scan could stay open. A toplevel slice was equal to any other toplevel slice spanning the same range, so two slices of two different files of the same length compared equal, and the second was silently dropped from the set of open slices. On Windows that stops the file from being deleted.

  • A lazily-created zip entry slice is now published safely (the cache field is volatile), so a slice created by one thread is seen intact by another thread reading the same entry.

  • ClassfileReader.close() leaked the underlying Resource if closing the inflater stream threw, since both were closed in one try block.

  • NestedJarHandler.spillToDisk wrote the whole buffer to the temporary file rather than just the bytes that had been read into it.

Bug fixes: paths, URLs and cross-platform behavior

  • Path canonicalization now goes through one shared routine, so a file has one identity however it is reached. Two different APIs were in use — Path#toRealPath() in the scanner, File#getCanonicalFile() elsewhere. They agree on Linux and macOS, but on Windows File#getCanonicalFile() resolves neither symlinks and junctions nor 8.3 short names, so a jarfile reached two ways was opened twice and reported under two paths. Reachable through a nested jar entry (outer.jar!/inner.jar), whose outer jar is a string and so never passes through the scanner's normalization. A path that does...
Read more

classgraph-4.8.189

Choose a tag to compare

@lukehutch lukehutch released this 08 Aug 19:52

This release is the result of a file-by-file audit of the whole codebase. None of the bugs below had been reported — they were found by reading the code. Each one that could be triggered through the API has a regression test that was checked to fail before the fix was applied.

Bug fixes: class metadata

  • ClassInfo#getClassesWithFieldAnnotation() passed the classes with an annotated method as its set of directly-annotated classes, so calling directOnly() on the result returned the classes with an annotated method rather than the classes with an annotated field.

  • ScanResult#getClassesWithAllAnnotations() and #getClassesWithAnyAnnotation() threw NullPointerException when called with no annotation names, rather than returning the empty list: the result was sorted before it was tested for null.

  • ModuleInfo#getClassInfo() and #getClassInfo(String) threw NullPointerException for a module with no accepted classes. A ModuleInfo is created as soon as any classfile is read from a module, including a module-info.class file, which does not itself contribute a ClassInfo. The sibling package accessors have always handled this.

  • ArrayTypeSignature#getClassName() was implemented as toString(), so the array class name included any type annotations and type arguments, e.g. "java.lang.String @Ann []" or "java.util.List<java.lang.String>[]". Neither is a class name, and this name is used both as the cache key for ArrayClassInfo and as the name to classload by.

  • A type parameter named Object caused a ClassCastException while rendering a type signature. The suppression of a redundant extends java.lang.Object bound detected the simple-name form of the bound by string comparison, then cast it to ClassRefTypeSignature; a type parameter may legally shadow java.lang.Object, in which case the bound is a TypeVariableSignature.

  • A truncated or malformed type signature threw IllegalArgumentException rather than ParseException, which is what the callers of the signature parser catch (they log and skip the offending constant pool entry). The end of the string is a valid parser position, but Parser#advance() rejected it, so a signature ending in $ or . escaped as the wrong exception type.

  • ClassInfo#getOrCreateClassInfo() mis-handled being passed a class descriptor ("Ljava/lang/String;") rather than a class name: it stripped the descriptor by keeping only its last character, instead of removing the leading L and the trailing ;.

  • ResourceList#getPathsRelativeToClasspathElement() returned the same paths as getPaths(), since it called Resource#getPath() rather than Resource#getPathRelativeToClasspathElement(), so the package root prefix was not stripped.

  • ObjectTypedValueWrapper#equals() ignored its boolean[], char[] and double[] fields (hashCode() hashes all of them), so wrappers holding different arrays of those types compared equal.

  • An annotation with an array-typed parameter could not be instantiated when the annotation's own classfile was not scanned. The element type is then inferred from the array elements; that fallback did not handle String elements, and for anything it did not recognize it returned the type of the wrapper object rather than Object, so an array of the wrong element type was allocated.

  • FieldInfo#toString() did not escape a single quote in a char constant initializer value. It used replaceAll("'", "\\'"), and in a replaceAll replacement string a backslash escapes the character that follows it, so the quote was replaced with itself.

  • toStringWithSimpleNames() left parts of the output fully qualified: for a generic class, the type parameter bounds, superclass and superinterfaces; and for a method type signature, the return type.

Bug fixes: zipfile and classfile reading

  • A zip entry whose local header declares a filename or extra field of 32768 bytes or more could not be read. Both lengths are unsigned 16-bit values but were read as signed shorts, so the computed start of the entry's data pointed before the local header instead of after it.

  • The last four bytes were dropped from every entry name read from an Info-ZIP Unicode path extra field (tag 0x7075). The data area is version(1) + nameCRC32(4) + name, so the name is size - 5 bytes long, but it was read as size - 9 bytes.

  • skip() on the InputStream for a deflated zip entry skipped the whole stream and returned a negative count, because the skip loop subtracted rather than added the number of bytes read.

  • ClassfileReader#readString(int) (the sequential overload) read out of an unfilled buffer. A reader built on an InputStream starts with an allocated but empty buffer, and every other sequential read method delegates to its random access counterpart, which buffers the requested range first.

  • Two bugs in the reader used for memory-mapped files (enableMemoryMapping()): readUnsignedShort() masked with 0xff rather than 0xffff, discarding the high byte; and readString() applied the slice offset a second time, to a buffer that had already had it applied.

  • Nested jars extracted to RAM from the same outer zipfile shared an identity key, since the key was the outermost File (which is also null for Path-backed zipfiles). The path string is now used.

  • Fixed a potential overflow in Slice#skip() for a very large skip count, and a case where a non-positive skip count could seek backwards.

Bug fixes: classpath and classloading

  • ClassGraphClassLoader#getResource(), #getResources() and #getResourceAsStream() did not follow the delegation order of findClass(String). They dereferenced both classloader delegation orders without a null check (the first entry of the environment order is a null ClassLoader, standing for the bootstrap classloader, and the added order is null unless addClassLoader() was called), and they never delegated to the override classloaders, which is where an overridden classpath ends up — so a resource on an overridden classpath that was not accepted by the scan spec could not be found at all. They now use the same delegation order as findClass(String), and always fall back to the bootstrap classloader. getResources() also now returns the resources found by every classloader, in delegation order and deduplicated by URL, rather than only those found by the first classloader that had any.

  • A , was accepted as part of a URL scheme when deciding whether a classpath element is a URL: the scheme pattern contained +-., which is a character range from + to ., and so also matched , and /.

  • A path element containing the path separator character did not survive a classpath round trip. It is escaped as \: when the classpath string is built, but the escape was written with replaceAll(), whose replacement string treats a backslash as an escape character, and the splitter did not honour the escape when reading the string back.

  • URLPathEncoder#normalizeURLPath() stripped only four characters of the file: prefix, leaving a stray :, and could produce a path with a run of slashes in it for a URL with an empty authority (file:// or file:///).

  • The JBoss and Quarkus classloader handlers threw when a field or method they read by reflection was absent. Quarkus renames these fields between releases; the reflection helpers return null in that case, and the handlers now check for it (as they already did for other members).

  • SingletonMap wrapped an InterruptedException in a NewInstanceException, so a cancelled scan looked like a failed instantiation. The interrupt status is now restored and the exception propagated.

  • The equals() method of an instantiated annotation proxy compared against the invocation handler, which is never the object equals() was called with, rather than against the proxy.

  • System.getProperty("user.dir") in FileUtils is now read through VersionFinder#getProperty(), like every other property read, so it cannot throw under a security manager.

Bug fixes: JSON serialization

(ScanResult#toJSON() / #fromJSON().)

  • An enum constant with a constant-specific class body was serialized as an empty object rather than as its name, and then could not be deserialized at all. Such a constant is an instance of an anonymous subclass of the enum type, and Class#isEnum() is false for that subclass.

  • A map whose keys are not Comparable was serialized with each key paired with a different key's value. Keys are sorted so that the serialized form is deterministic; for non-comparable keys (a Class<?> is a legal map key) the stringified keys were sorted after the values had already been collected in the map's iteration order.

  • A Class<?>-typed field with an explicit JSON null value could not be deserialized, because the null check treated every type other than NON_PRIMITIVE as a primitive type.

API consistency

  • ScanResult#getAllResources(), #getAllResourcesAsMap() and #getModulePathInfo() now throw if the ScanResult has been closed, as the other ScanResult methods do.

  • ClassGraph#enableMultiReleaseVersions() disables every other classfile reading feature, but it set disableRuntimeInvisibleAnnotations to false, which has the opposite polarity to the rest of that block — so it enabled the reading of runtime-invisible annotations instead of disabling it.

  • ClassInfo#getSuperclass() only threw if there was more than one entry besides the first in the superclass set. Interfaces are not held in that set, so the check is now for more than one entry, and should never fire.

Internal

  • MethodInfo#findReferencedClassInfo() now adds the exception types in a method's throws clause to the set of referenced classes. This is not a behavioral change: they were already reported by `ClassInfo#getClassD...
Read more

classgraph-4.8.188

Choose a tag to compare

@lukehutch lukehutch released this 08 Aug 11:29

Bug fixes

  • Fixed a StringIndexOutOfBoundsException in ScanResult#getResourcesWithPath(String) and getResourcesWithPathIgnoringAccept(String) for any path that normalizes away to nothing but slashes, such as "/..", "/." or "/a/..". Path sanitization stripped the leading slash before the trailing ones, so truncating the buffer could leave the start index past its end. (Found by audit; no issue was filed.)

  • Fixed Equinox system bundles being omitted from every scan after the first in a JVM (follow-up audit after #810 and #913). The "system bundles have already been read" flag was static, so it stayed set once the first scan had run. It is now scoped to a single scan. This also removes a race between two Equinox classloaders processed concurrently.

  • Fixed a race in ClassGraph#getModulePathInfo() and ScanResult#getModulePathInfo() (follow-up audit after #810 and #913). The lazy population of the module path fields was guarded by an atomic test-and-set, which made the flag flip atomic but let a second concurrent caller return immediately and read the modulePath, addModules, patchModules, addExports, addOpens and addReads sets while the first caller was still filling them in. The method now blocks instead.

  • Fixed a data race in FileUtils#closeDirectByteBuffer (follow-up audit after #810 and #913). Two threads unmapping a buffer for the first time concurrently could both run the reflective handle lookup and race on the static fields it assigns. Replaced with double-checked locking, so a late arriver waits for the handles rather than reading them half-initialized. The lazily-initialized FileUtils#currDirPath and NestedJarHandler#runFinalizationMethod are now volatile as well.

  • URL schemes are now lower-cased with Locale.ROOT (#936, thanks to @koteshyelamati), so scheme matching no longer depends on the default locale (for example, the Turkish dotless-i locale).

Performance

  • Resource#getPath() for directory classpath elements now computes the relative path once when the Resource is created, rather than on every call (#935, thanks to @freya022).

  • FileUtils#sanitizeEntryPath no longer copies the path into a char[] on every call (#935). The common case is that a path needs no sanitizing at all, so the copy was pure overhead. Note that the other half of #935 — replacing the exact segment scan with a few String#contains tests — was deliberately not taken: such a test misses a trailing . or .. segment, cannot distinguish a nested-jar separator ! from an ordinary ! in a filename (#903), and over-triggering is not harmless, because the sanitizing branch also drops empty segments.

Internal

  • ClassLoaderHandler is now an interface with instance methods, rather than a set of statics looked up reflectively (#934, thanks to @freya022). getPackageRootPrefixes() (#929) becomes an interface method too. This narrows the unexported nonapi classloaderhandler package, so only plain-classpath users with a custom ClassLoaderHandler are affected.

  • Added an end-to-end acceptPaths() test for a mid-path ** glob (#940).

  • Noted in Classfile that the constant pool tag and attribute lists are current as of JDK 26.

  • Removed the Dependabot configuration, to stop automated dependency PRs.

  • Build and CI updates: Maven 3.9.16, Maven wrapper 3.3.4, GitHub Actions updated to Node 24 majors, with the Maven wrapper and repository now cached.

classgraph-4.8.187

Choose a tag to compare

@lukehutch lukehutch released this 08 Aug 01:11

A small bugfix release: mid-pattern ** package globs (fixing a 4.8.186 regression), and an end to the Unsafe::invokeCleaner deprecation warning on modern JDKs.

Behaviour changes

  • **, used as a complete glob segment, now matches zero or more package segments, and may appear anywhere in the pattern (#940, thanks to @big-andy-coates). The glob rework in 4.8.186 made * match only a single package segment, which broke patterns such as org.creekservice.*.schema that previously relied on * spanning several segments. ** now fills that role explicitly: acceptPackages("org.creekservice.**.schema") matches org.creekservice.api.base.schema — and, because ** matches zero or more segments, org.creekservice.schema as well. The same applies to path globs (acceptPaths() etc.), where ** as a complete segment matches zero or more whole path segments.

Bug fixes

  • On JDK 22+, ByteBuffers are now allocated and memory-mapped with the java.lang.foreign.Arena API, and freed/unmapped by closing their arena, instead of calling sun.misc.Unsafe::invokeCleaner (#939, thanks to @aac1122). This eliminates the startup warning WARNING: A terminally deprecated method in sun.misc.Unsafe has been called that JDK 24+ prints for every ClassGraph scan, and future-proofs ClassGraph against the planned removal of Unsafe::invokeCleaner. On JDK 9–21, where the java.lang.foreign API is not available, invokeCleaner is still used.
  • ByteBuffer-to-Buffer casts are now routed through a helper method that IDE cleanups cannot remove (#284, thanks to @bbougon). JDK 9 changed several Buffer methods to covariantly return ByteBuffer, so a statically-superfluous-looking cast is all that stands between bytecode compiled on JDK 9+ and a NoSuchMethodError on JDK 8 — and as an inline cast, it kept getting "simplified" away, re-introducing the crash.