Repository navigation
Releases: classgraph/classgraph
Release list
ClassGraph 4.8.196
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-PathandBundle-ClassPathmanifest attributes are now read with their specified syntax. AClass-Pathentry is a relative URL, so its percent encoding is now decoded, as the JVM's own classloader decodes it. Before, a jarfile written asmy%20lib.jarwas looked for under that literal name, so a jarfile with a space in its name could not be named at all.Bundle-ClassPathpaths are now trimmed, unquoted and separated from their parameters, following the OSGi header syntax. Before,. , inner.jarnamedinner.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.ZipFileopens 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.createTempFilefail 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
HttpURLConnectiononly 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
SecurityManagerthat 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.
-
normalizePathdid not collapse//in a path that did not end with a separator, soa//bstayeda//bwhilea//b/becamea/b.
Bug fixes: memory, file handles and temporary files
-
A file is no longer memory-mapped when it could not be unmapped again. A
SecurityManagerthat denied access to the buffer cleaner madenew 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
scanAsyncfuture leaked itsScanResult. 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, sodeleteOnExit()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 withClass#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, asClass#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
ConstantValueattribute 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 aTused in an inner class resolves to theTof 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, sogetClassInfo()andgetAllClassesAsMap()returned it afterward. For aScanResultread back withfromJSON(), 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
NoClassDefFoundErrorfromgetDeclaredMethods()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 inOuter<TT;>.Inner$Namethe suffix isInner$Name, not the two suffixesInnerandName. A type annotation onInner$Namewas attached to a class namedName. -
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 asp.Outer<TT;>.Inner$. -
With simple names,
List<? extends Object>rendered asList<? extends Object>rather thanList<?>, and a type parameter namedT$Xwas cut toX. -
ClassTypeSignature#equals()now compares the class and the throws suffix, andTypeVariableSignature#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 orString[]arrays, as they already were for other annotations. An array value that had not yet been converted compared equal to the converted array incompareTo(), but not inequals(), 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, whileget(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 nofsuffix, and a long outside the int range with noLsuffix.- 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
ZipExceptionfrom 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...
ClassGraph 4.8.195
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). EveryFuture#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(). TheScannerwas constructed inside the task submitted to theExecutorService, 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
StackWalkeris 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 extractsClassGraph--<random>---<name>, andleafName()treated---anywhere in a path as that separator. A jarfile genuinely namedx---y.jarwas therefore reported asy.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 withClassGraph--, 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
IOExceptionfrom 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/classesthe sibling/a/classes-extrafalls 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: namingapp.jar!/WEB-INF/classeson 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-Releasekey; in one that does not, the JVM reads an entry underMETA-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 theFilebranch already did. -
A URL that was already percent-encoded was encoded a second time.
normalizeURLPathleft anhttp:,https:orjrt:URL alone and then passed it to the encoder written for decoded file paths, so%20became%2520, the colon before a port number became%3a, and a query string?v=1&t=2became%3fv%3d1%26t%3d2— after whichURI#getHostreturned null andURI#getPortreturned -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
ClassLoaderHandlercan handle the same classloader — one that a handler recognizes by name may also extendURLClassLoader— 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 aLinkedHashSet, so the classes of the one that was dropped were never scanned. Separately,ClassLoaderOrderdeduplicated 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 ofCxfContainerClassLoaderequal to theTomEEWebappClassLoaderit 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-buddhistorja-JP-u-ca-japanesean entry timestamped September 2020 was reported as September 1477. Only an entry with no extended timestamp extra field is affected, which is whatZipOutputStreamwrites whenZipEntry#setTime(long)is the only time that was set. -
A read into a
ByteBufferthat carried a limit from a previous read threwIllegalArgumentException. 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 exceptionsInputStream.read(byte[], int, int)is allowed to throw for a valid range. Three of the four readers already opened the limit before positioning;ClassfileReaderdid not, so the same call sequence failed or succeeded depending on where the content was being read from. The file-channel reader reuses oneByteBufferacross 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
maxBufferedJarRAMSizeeven for a stream holding a few bytes, and a stream whose length hint understated it was spilled to a temporary file r...
ClassGraph 4.8.194
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()andscan(int)submitted theScannerto anExecutorServiceand blocked the calling thread on theFuture, 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 inClassLoader.loadClasswhile 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, andjstackshowed nothing wrong. TheScannernow runs on the calling thread; theExecutorServiceis still used for the parallel stages of the scan, and is not used at all whennumParallelTasksis 1, soscan(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
ClassLoaderHandlerdecides where its ancestors' classpath elements go relative to its own — so pinning an ancestor first silently converted parent-last delegation, the default for Tomcat'sWebappClassLoaderand 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 howClass.forName(String)resolves against its immediate caller. Classloaders added withaddClassLoader()are still appended after them, as that method documents. -
The application classpath was searched after the classloaders that delegate to it. The
java.class.pathentries 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 makesignoreParentClassLoaders()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 ajdk.internal.loader.Loader, whoseloadClasschecks 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 theuno-jar.class.pathsystem property, separated by|. A JBossResourceLoaderthat wraps another one, such as aFilteredResourceLoader, exposes only the location of the loader it delegates to. An EquinoxBundleFileWrapperinstalled by a framework extension copies only the base file of the bundle file it wraps, so without following itsbundleFilefield the sub-path within the bundle is lost. A FelixContentwith no file of its own delegates to theContentin itsm_contentfield. 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 LibertyAppClassLoaderdelegates to the classloaders of its configured libraries, split by precedence intobeforeAppDelegateLoadersandafterAppDelegateLoaders, and aThreadContextClassLoadersearches the classloaders infollowOnClassLoadersafter 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, andResource#getURI()appended!/between the URI of the classpath element and the path of the resource within it, givingapp.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 theclass_info_indexof a tagcannotation element value refers to aCONSTANT_Classentry, but javac writes the type descriptor directly as aCONSTANT_Utf8entry instead, which is whatAnnotationClassRefexpects. 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_Classreferences are now converted to type descriptors, and UTF8 constants are passed through unchanged. -
Mixing
RUNTIME- andCLASS-retention type-use annotations on one declaration dropped all theRUNTIMEones. javac emits both theRuntimeVisibleTypeAnnotationsandRuntimeInvisibleTypeAnnotationsattributes 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
tryblocks, 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 anextendsorimplementsclause, as Java source does. It previously rendered a superclass or superinterface with its own modifiers, class type keyword andextends/implementsclauses, 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 TagThe 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_ENCAPSULATIONjavadoc 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
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
ScanResultcould 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: aRandomAccessByteBufferReaderkeeps 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 isUnsafe::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
ScanResultmerely 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 theScanResultwas 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, withUnsafe::invokeCleaneron JDK 9 to 21, and withsun.misc.Cleanerbelow 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 theRandomAccessFileAPI 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
NestedJarHandlerwas closed only when there was noFailureHandler, or when theFailureHandleritself threw; aFailureHandlerthat returned normally fell through to a step that deletes temporary files but closes nothing. Three sibling leaks were fixed with it: theScannerconstructor released the handler only onInterruptedException, so a throwing classpath element filter leaked the whole handler with no way for the caller to reach it; aScanResultProcessorthat threw anError— which is what a failing assertion inside one throws — skipped bothclose()calls, and nothing else can close thatScanResult; and aScanResultthat was garbage collected without being closed left a deadWeakReferencehusk 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#checkCanOpenset the open flag before checking whether theScanResulthad 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
ScanResultwas closed either threw or leaked. Closing aScanResultforce-closes the inflater and module-reader recyclers, but a recycler kept no memory of that: it drained the pool and leftrecycle()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 theclose()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 theInflaterorModuleReaderit held. Closing a resource stream after closing theScanResultreaches the first case. A force-close is now terminal.
Bug fixes: zipfile reading
-
A zipfile comment containing the bytes
PK\x05\x06made 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, itsBundle-ClassPathand everything else the manifest says.java.util.zip.ZipFilefinds that manifest, because it matchesMETA-INF/andMANIFEST.MFa 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.CLASSaborted the entire scan.JarUtils.classfilePathToClassNamethrew 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.classandpackage-info.classstay 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 thejar:URL scheme requires.sun.net.www.protocol.jar.Handlerlooks for the first!that is immediately followed by/, andjava.net.JarURLConnectionrejects 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, soouter.jar!/dir!namewas split insidedir!name, and normalizingouter.jar!/dir!name/x.txtproduced a URL naming an entry that does not exist. Which!characters separate is now decided by how the outermost one is spelled, andJarUtils#toJarUrlSeparatorsis 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/, sofoo:baris spelled exactly like a URL whose scheme isfoo. The syntactic rule is now applied only after the filesystem has been asked, socgtest:relpath/dir!name/y.jaris no longer split at the!whilereldir/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
ClassLoaderHandlerregistry entries hadnullcached 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, sincenullis 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...
ClassGraph 4.8.192
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.zipdoes.
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, butcapacity()was the size of the whole jarfile, an absolute read such asget(0)read from the start of the jarfile, andclear()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-Pathmanifest entry (orlibdirectory) 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 theClass-Pathentries 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
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 examplejar: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 equivalentjar:URL, and a classpath string containing one is no longer split at thenested: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. Adir/*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
IllegalArgumentExceptionout 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 throwIOException. -
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
Resourcewhose open failed with anIOExceptionwas left marked as open. -
A module resource that could not be read threw
IllegalArgumentExceptionout ofResource#read()and#open(), left the resource marked as open, and leaked theModuleReaderfrom the recycler. It now throwsIOException.
Bug fixes: classpath finding
-
A
Class-Pathmanifest 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,FallbackClassLoaderHandlerasks 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 ofmodule-info.classwas 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 companionclasspathContentsModifiedSinceScan()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 throughFile#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
ExecutorServicewith 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.logInRealtimeis nowvolatile: it is written from the caller's thread and read by theLogNodeconstructor on the scan threads.
ClassGraph 4.8.190
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
delegateflag — 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()andgetClasspath(), 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, soacceptClasses("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 bygetResourcesMatchingWildcard(), 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 afterenableSystemJarsAndModules().rejectModules("jdk.compiler")no system module was scanned at all, not evenjava.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 declaresimplements;getClassesWithAnnotation()on an accepted@Inheritedannotation missed an accepted class inheriting it from an external superclass; andgetClassesWithFieldAnnotation()/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/bis now read as a relative path rather than as a URL with schemeC:. A single letter is no longer accepted as a URL scheme, matchingJarUtils.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 deploywithout-Preleaseno longer publishes to Maven Central, and fails with a missing distribution repository instead.central-publishing-maven-pluginmoved into thereleaseprofile, 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
nowrapinflater 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#loadspun until it died withOutOfMemoryError: Required array size too large, and a discarding read loop never terminated at all. AnEOFExceptionis now thrown the second time the inflater runs out of input.mark()is also now a no-op andreset()throwsIOException, as theInputStreamcontract requires of a stream whosemarkSupported()is false — both previously threw the uncheckedIllegalArgumentExceptioninto 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
FileChannelwas reported as a truncated file.FileChannel#readis 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
InputStreamwas reported asBuffer underflow. Both streams thatClassfileReaderis built over in production are channel-backed and really can transfer less than asked: a module read throughModuleReader, and a directory entry read throughFiles.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 aByteBufferalso copied the whole rest of the slice, overflowing the destination. -
RandomAccessFileChannelReaderletByteBuffer#limitthrowIllegalArgumentExceptionwhen 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 of0x01bytes. 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 sameResource. -
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 underlyingResourceif closing the inflater stream threw, since both were closed in onetryblock. -
NestedJarHandler.spillToDiskwrote 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 WindowsFile#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...
classgraph-4.8.189
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 callingdirectOnly()on the result returned the classes with an annotated method rather than the classes with an annotated field. -
ScanResult#getClassesWithAllAnnotations()and#getClassesWithAnyAnnotation()threwNullPointerExceptionwhen 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)threwNullPointerExceptionfor a module with no accepted classes. AModuleInfois created as soon as any classfile is read from a module, including amodule-info.classfile, which does not itself contribute aClassInfo. The sibling package accessors have always handled this. -
ArrayTypeSignature#getClassName()was implemented astoString(), 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 forArrayClassInfoand as the name to classload by. -
A type parameter named
Objectcaused aClassCastExceptionwhile rendering a type signature. The suppression of a redundantextends java.lang.Objectbound detected the simple-name form of the bound by string comparison, then cast it toClassRefTypeSignature; a type parameter may legally shadowjava.lang.Object, in which case the bound is aTypeVariableSignature. -
A truncated or malformed type signature threw
IllegalArgumentExceptionrather thanParseException, 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, butParser#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 leadingLand the trailing;. -
ResourceList#getPathsRelativeToClasspathElement()returned the same paths asgetPaths(), since it calledResource#getPath()rather thanResource#getPathRelativeToClasspathElement(), so the package root prefix was not stripped. -
ObjectTypedValueWrapper#equals()ignored itsboolean[],char[]anddouble[]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
Stringelements, and for anything it did not recognize it returned the type of the wrapper object rather thanObject, so an array of the wrong element type was allocated. -
FieldInfo#toString()did not escape a single quote in acharconstant initializer value. It usedreplaceAll("'", "\\'"), and in areplaceAllreplacement 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 isversion(1) + nameCRC32(4) + name, so the name issize - 5bytes long, but it was read assize - 9bytes. -
skip()on theInputStreamfor 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 anInputStreamstarts 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 with0xffrather than0xffff, discarding the high byte; andreadString()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 forPath-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 offindClass(String). They dereferenced both classloader delegation orders without a null check (the first entry of the environment order is a nullClassLoader, standing for the bootstrap classloader, and the added order is null unlessaddClassLoader()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 asfindClass(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 withreplaceAll(), 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 thefile:prefix, leaving a stray:, and could produce a path with a run of slashes in it for a URL with an empty authority (file://orfile:///). -
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).
-
SingletonMapwrapped anInterruptedExceptionin aNewInstanceException, 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 objectequals()was called with, rather than against the proxy. -
System.getProperty("user.dir")inFileUtilsis now read throughVersionFinder#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
Comparablewas 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 (aClass<?>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 thanNON_PRIMITIVEas a primitive type.
API consistency
-
ScanResult#getAllResources(),#getAllResourcesAsMap()and#getModulePathInfo()now throw if theScanResulthas been closed, as the otherScanResultmethods do. -
ClassGraph#enableMultiReleaseVersions()disables every other classfile reading feature, but it setdisableRuntimeInvisibleAnnotationsto 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'sthrowsclause to the set of referenced classes. This is not a behavioral change: they were already reported by `ClassInfo#getClassD...
classgraph-4.8.188
Bug fixes
-
Fixed a
StringIndexOutOfBoundsExceptioninScanResult#getResourcesWithPath(String)andgetResourcesWithPathIgnoringAccept(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()andScanResult#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 themodulePath,addModules,patchModules,addExports,addOpensandaddReadssets 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-initializedFileUtils#currDirPathandNestedJarHandler#runFinalizationMethodare nowvolatileas 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 theResourceis created, rather than on every call (#935, thanks to @freya022). -
FileUtils#sanitizeEntryPathno longer copies the path into achar[]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 fewString#containstests — 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
-
ClassLoaderHandleris 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 unexportednonapiclassloaderhandler package, so only plain-classpath users with a customClassLoaderHandlerare affected. -
Added an end-to-end
acceptPaths()test for a mid-path**glob (#940). -
Noted in
Classfilethat 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
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 asorg.creekservice.*.schemathat previously relied on*spanning several segments.**now fills that role explicitly:acceptPackages("org.creekservice.**.schema")matchesorg.creekservice.api.base.schema— and, because**matches zero or more segments,org.creekservice.schemaas 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.ArenaAPI, and freed/unmapped by closing their arena, instead of callingsun.misc.Unsafe::invokeCleaner(#939, thanks to @aac1122). This eliminates the startup warningWARNING: A terminally deprecated method in sun.misc.Unsafe has been calledthat JDK 24+ prints for every ClassGraph scan, and future-proofs ClassGraph against the planned removal ofUnsafe::invokeCleaner. On JDK 9–21, where thejava.lang.foreignAPI is not available,invokeCleaneris still used. ByteBuffer-to-Buffercasts are now routed through a helper method that IDE cleanups cannot remove (#284, thanks to @bbougon). JDK 9 changed severalBuffermethods to covariantly returnByteBuffer, so a statically-superfluous-looking cast is all that stands between bytecode compiled on JDK 9+ and aNoSuchMethodErroron JDK 8 — and as an inline cast, it kept getting "simplified" away, re-introducing the crash.