-
-
Notifications
You must be signed in to change notification settings - Fork 309
Vfs API
The classgraph-vfs library is a read-only virtual filesystem. It reads directories, jarfiles and modules through one interface, however they are named, and lets you read what you find as a stream, a channel, a byte array, a string, a ByteBuffer, or a java.nio.file.Path. It is the storage layer that ClassGraph itself scans through, and it can be used on its own, with no scanning involved.
<dependency>
<groupId>io.github.classgraph</groupId>
<artifactId>classgraph-vfs</artifactId>
<version>LATEST</version>
</dependency>If you already depend on classgraph, you have this library too: classgraph depends on classgraph-classpath, which depends on classgraph-vfs, which depends on classgraph-base.
Three classes carry the API: Vfs opens things, a VfsRoot is one opened directory, jarfile or module, and a VfsEntry is one file within it. Two more turn up as you use them: CloseableByteBuffer wraps a buffer you have to close, and VfsVisitor is the callback that VfsRoot#walk() takes. That is the entire public API.
import io.github.classgraph.vfs.*;
try (Vfs vfs = new Vfs();
VfsRoot root = vfs.open("outer.jar!/lib/inner.jar")) {
for (VfsEntry entry : root) {
System.out.println(entry.getName() + " (" + entry.getLength() + " bytes)");
}
}- One interface over every kind of storage
- Reading through java.nio.file
- Nested jarfiles, package roots and multi-release jarfiles
- Vfs
- VfsRoot
- VfsEntry
- VfsVisitor
- Lifetime and closing
- Thread safety
- What is not reported
The point of the library is that the same three lines of code read all of these, and nothing in them says which kind of storage is being read:
| The thing you have | How to open it |
|---|---|
| A path, as a string | vfs.open("/path/to/classes") |
A File
|
vfs.open(file) |
A Path, in any filesystem provider |
vfs.open(path) |
A URI or a URL
|
vfs.open(uri), vfs.open(url)
|
| A jarfile at an HTTPS address |
vfs.open("https://.../lib.jar"), on a Vfs constructed with "https" as an allowed scheme |
| A jarfile inside another jarfile, to any depth | vfs.open("outer.jar!/lib/inner.jar") |
| A package root inside a jarfile | vfs.open("app.jar!/BOOT-INF/classes") |
| A module | vfs.open(moduleReference) |
| A jarfile arriving on a stream | vfs.open(inputStream, "name.jar") |
| A jarfile already in memory | vfs.open(bytes, "name.jar") |
Every one of those returns a VfsRoot, and every VfsRoot lists VfsEntry objects: it is Iterable<VfsEntry>, so a for-each loop over the root visits them all, and getEntries(), getEntries(prefix) and walk() are there for the cases a for-each loop does not cover. root.getKind() tells you which of DIRECTORY, ARCHIVE or MODULE you got, if you care; most code does not need to.
Once you have a VfsEntry, there are seven ways to read it, so that you can hand its content to whatever the next piece of code expects without a copy in between:
| Read it as | Method | Notes |
|---|---|---|
InputStream |
entry.open() |
Decompressed on the fly. You own it, and must close it. |
ReadableByteChannel |
entry.openChannel() |
Likewise. |
ByteBuffer |
entry.read() |
Returns a CloseableByteBuffer, which you must close. Where the entry is stored uncompressed in a file that is memory-mapped, this is a slice of the mapping, with no copy; otherwise the content is read into a buffer of its own. |
byte[] |
entry.load() |
Your own copy, so it stays valid after the Vfs is closed. |
String |
entry.loadAsString() |
Decoded as UTF-8. |
Path |
entry.asPath() |
A path in the read-only filesystem view of the root, for code that reads through java.nio.file.Files. |
Path in the real filesystem |
entry.getNioPath() |
Null unless the entry really is a file this JVM has mounted, i.e. only for a directory root. |
For example, this reads one file out of a jarfile nested inside another jarfile, decodes it, and never writes anything to disk:
try (Vfs vfs = new Vfs();
VfsRoot root = vfs.open("/path/to/outer.jar!/BOOT-INF/lib/inner.jar")) {
VfsEntry entry = root.getEntry("META-INF/MANIFEST.MF");
if (entry != null) {
System.out.println(entry.loadAsString());
}
}root.asFileSystem() returns a read-only java.nio.file.FileSystem over any root, so anything that takes a Path can read a directory, a jarfile, a nested jarfile, a package root, a module, or a jarfile that exists only in RAM, without knowing which it has:
try (Vfs vfs = new Vfs();
VfsRoot root = vfs.open("/path/to/outer.jar!/BOOT-INF/lib/inner.jar");
FileSystem fs = root.asFileSystem()) {
byte[] classfile = Files.readAllBytes(fs.getPath("/com/xyz/Widget.class"));
try (Stream<Path> paths = Files.walk(fs.getPath("/"))) {
paths.filter(Files::isRegularFile).forEach(System.out::println);
}
}The separator is / and the root directory is /, whichever kind of root it is a view of. Files are read with Files.newInputStream, Files.newByteChannel, Files.readAllBytes and Files.readString; directories are listed with Files.newDirectoryStream, Files.list and Files.walk, and are synthesized from the names of the entries below them, since a jarfile need not contain an entry for every directory whose contents it holds. getPathMatcher supports both glob: and regex:. basic is the only attribute view.
Everything that would write -- Files.delete, Files.write, Files.copy, Files.move, Files.createDirectory, Files.newOutputStream, setTimes, and newByteChannel with a write option -- throws ReadOnlyFileSystemException. FileSystem#close() closes the VfsRoot it is a view of, and closing the root closes the filesystem, so either one can go in the try-with-resources; after that isOpen() returns false and every read throws ClosedFileSystemException, as java.nio.file.FileSystem specifies. Neither of them releases any storage: the file handles, memory mappings and temporary files belong to the Vfs, and are released when the Vfs is closed, which also makes every filesystem view it handed out report itself closed.
The JDK ships a zip filesystem provider, and it is the right tool when you have a jarfile you can name as a Path. It cannot open a module, and it cannot open a jarfile that was never written to disk. It also cannot name a nested jarfile: given jar:file:/path/to/outer.jar!/BOOT-INF/lib/inner.jar it keeps only the part before the first !/, and silently opens outer.jar instead, so the entries of inner.jar are not there.
A jarfile inside another jarfile is named by separating the two with !/, to any depth:
try (VfsRoot inner = vfs.open("outer.jar!/lib/inner.jar!/lib/innermost.jar")) {
// ...
}A nested jarfile that is stored uncompressed is read in place, with no copy. One that is stored deflated is inflated into RAM, or spilled to a temporary file if it exceeds the maximum buffered jar RAM size (64MB by default); any such file is deleted when the Vfs is closed. Construct the Vfs with nested jars disabled if !/ should only ever mean a package root.
A jar: or jar:file: prefix is accepted, but is not needed and changes nothing: /path/outer.jar!/lib/inner.jar, file:/path/outer.jar!/lib/inner.jar and jar:file:/path/outer.jar!/lib/inner.jar all open the same nested jarfile. Which ! separates the levels is not decided by the prefix, because it cannot be: ! is a legal filename character on every platform, JarURLConnection defines the separator as !/ and offers no way to escape a literal one, so /dir!/x.jar is genuinely ambiguous between a jarfile x.jar in a directory named dir! and an entry x.jar inside a jarfile named dir. It is decided by looking at the storage instead: each ! is tried in turn, and the first one whose preceding path names an existing file is the separator, since the outermost jarfile has to exist to be read at all. A path with a remote URL scheme cannot be checked that way, so for those the first ! is taken to be the separator.
If the last !/ section names a directory rather than a jarfile, it is a package root: only the entries below it are reported, and the root is stripped from their names. This is how a Spring Boot jarfile's classes are read:
try (Vfs vfs = new Vfs();
VfsRoot classes = vfs.open("/path/to/app.jar!/BOOT-INF/classes")) {
// An entry stored as "BOOT-INF/classes/com/xyz/Widget.class"
// is reported as "com/xyz/Widget.class"
VfsEntry widget = classes.getEntry("com/xyz/Widget.class");
System.out.println("package root: " + classes.getPackageRoot());
}VfsRoot#getPath() returns the jarfile's path without the package root; VfsRoot#getPackageRoot() returns the package root, or the empty string if the whole jarfile is the root.
For a multi-release jarfile, only the newest version of each entry that the running JVM can use is reported, and the META-INF/versions/<version>/ prefix is stripped from the name -- so getEntry("com/xyz/Widget.class") returns the right version of the class for this JVM, and META-INF/versions/ never appears in an entry name. Construct the Vfs with multi-release versions enabled to see every version instead.
A Vfs opens things, and owns the file handles, memory mappings and temporary files that back them. It is AutoCloseable, and belongs in a try-with-resources block.
A directory or jarfile is opened once however it is named, so the same VfsRoot comes back for a plain path, for the file: or jar: URL of the same thing, for a path that reaches it through a symlink, and, on Windows, for a path written with backslashes rather than forward slashes, or one that names a directory by its 8.3 short name. A root names itself at the canonical path, so getPath() is not always the path you opened it by.
| Method | Effect |
|---|---|
VfsRoot open(String path) |
Open a directory or jarfile named by a path, which may contain !/ sections and may be a URL. Throws IOException if it could not be opened or read. |
VfsRoot open(File file) |
Open a directory or jarfile named by a File. |
VfsRoot open(Path path) |
Open a directory or jarfile named by a Path, in any filesystem provider, not only the default one. |
VfsRoot open(URI uri)VfsRoot open(URL url)
|
Open a directory or jarfile named by a URI or URL. file: and jar: always work; any other scheme has to be one the Vfs was constructed with. |
VfsRoot open(ModuleReference ref) |
Open a module. |
VfsRoot open(InputStream in, String name) |
Read a jarfile from a stream. The stream is read in full, into RAM or a temporary file. |
VfsRoot open(byte[] bytes, String name) |
Read a jarfile you already hold in memory. |
boolean hasTempFiles() |
Whether anything read through this Vfs had to be extracted to a temporary file, i.e. whether closing it will have to delete anything from disk. |
void close() |
Close every root this Vfs opened, release every file handle and memory mapping, and delete every temporary file. |
A Vfs is configured by its constructor and cannot be reconfigured afterwards, so that the threads sharing it never see a setting change under them. new Vfs() uses the default of every option; the four-argument constructor sets them all:
new Vfs(/* enableNestedJars = */ true, /* enableMultiReleaseVersions = */ false,
/* urlSchemes = */ Set.of("https"), /* maxBufferedJarRAMSize = */ 64 * 1024 * 1024)| Parameter | Default | Effect |
|---|---|---|
boolean enableNestedJars |
true |
Whether !/ in a path may name a jarfile within a jarfile, rather than only a package root within a jarfile. |
boolean enableMultiReleaseVersions |
false |
Whether to report every version of a multi-release jarfile's entries, rather than only the newest version this JVM can use. |
Collection<String> urlSchemes |
none | The URL schemes a jarfile may be opened from, e.g. Set.of("https"). file: and jar: are always allowed. A jarfile read from a URL is downloaded in full first, since a zipfile's central directory is at the end of the file. Each scheme must be at least two characters, so that it cannot be confused with a Windows drive letter. May be null for none. |
int maxBufferedJarRAMSize |
64MB | The number of bytes of a jarfile that may be held in RAM before it is spilled to a temporary file. Only applies to jarfiles that cannot be read in place: a nested jarfile stored deflated, a jarfile downloaded from a URL, and a jarfile read from a stream. |
Vfs.DEFAULT_ENABLE_NESTED_JARS, Vfs.DEFAULT_ENABLE_MULTI_RELEASE_VERSIONS and Vfs.DEFAULT_MAX_BUFFERED_JAR_RAM_SIZE name the defaults, for setting one option and leaving the rest alone. An invalid URL scheme or a negative maxBufferedJarRAMSize throws IllegalArgumentException.
| Diagnostics | Effect |
|---|---|
Vfs verbose() |
Log what is read to the io.github.classgraph.ClassGraph logger, at INFO level, when the Vfs is closed. For diagnosis only; not a stable output format. Returns this, so it can be chained onto the constructor: try (Vfs vfs = new Vfs().verbose()). Throws IllegalStateException after the Vfs has been closed. |
A Vfs caches every root it opens, so opening the same path twice returns the same VfsRoot, and a jarfile that encloses several nested jarfiles is only read once.
One opened directory, jarfile or module. AutoCloseable.
| Method | Returns |
|---|---|
Kind getKind() |
DIRECTORY, ARCHIVE or MODULE. |
String getPath() |
The directory path, or the path of the jarfile with !/ separating each nested jarfile from the one enclosing it, or the module name. Does not include the package root. A directory or jarfile is named by its canonical path, so this is not always the path the root was opened by. |
URI getURI()URL getURL()
|
The location of the root. Throws IllegalStateException if one cannot be formed, which includes a module that does not know its own location. |
File getFile()Path getNioPath()
|
The directory or jarfile in the filesystem, or null if this root is a module, or a jarfile that was read from a stream or downloaded into RAM rather than to a temporary file. |
ModuleReference getModuleReference() |
The module, or null if this root is not a module. |
String getPackageRoot() |
The package root within the root, without a trailing /, or the empty string if the whole root is the package root. |
String getModuleName() |
The module's name for a module, or the Automatic-Module-Name manifest entry for a jarfile, or null if there is none. |
Map<String, String> getManifest() |
The main section of the root's META-INF/MANIFEST.MF, keyed case-insensitively by attribute name, or null if the root has no manifest file. Unmodifiable, read on first use and cached. A manifest is really only meaningful for a jarfile, but it is read the same way for a directory and for a module, so an exploded jarfile is described by its manifest just as the jarfile it was exploded from is. A root opened at a package root reports the manifest of the container it was opened within, since that is the one that describes the jarfile as a whole. |
String getManifestEntry(String key) |
The value of one manifest attribute, e.g. "Class-Path", or null if the root has no manifest file or its manifest does not declare that attribute. Attribute names are case insensitive. |
List<VfsEntry> getEntries() |
Every file under the package root, named relative to it, which is what iterating the root itself gives. Unmodifiable, and in a deterministic order: a jarfile's entries come back in the order its central directory holds them, which is the order they were written in; a module's sorted by name; and a directory tree's from the top down, each directory's own files before its subdirectories, with the children of a directory sorted by name. Does not include directories. |
List<VfsEntry> getEntries(String pathPrefix) |
The entries whose name starts with the prefix, in the same order and leaving out the same entries as getEntries(). A prefix ending in / names a directory, and everything beneath it is returned, however deeply nested; the empty string matches every entry. For a directory tree only the directories that could hold a match are listed, so listing "BOOT-INF/lib/" costs far less than listing the whole tree. |
VfsEntry getEntry(String name) |
The entry with that name, relative to the package root, or null if there is no readable entry with that name. If a jarfile holds more than one entry with the same name, the first is returned, which is the one a classloader would find. Null does not say which reason applies: for a directory, the name may not exist, may name a directory rather than a file, may name a file the process cannot read, or may point outside the root once .. sections are resolved; for a jarfile or module, it may not exist, or may be one of the entries that are not reported. Test for the file directly if the difference matters. |
void walk(VfsVisitor visitor) |
Offer every directory and entry to the visitor, which can skip what it does not want. See VfsVisitor. |
FileSystem asFileSystem() |
A read-only java.nio.file.FileSystem view of this root. The same view every time, and closing either one closes the other. |
Vfs getVfs() |
The Vfs this root was opened by. |
void close() |
Drop this root. Its entries stop working, its FileSystem view starts throwing ClosedFileSystemException, and opening the same path again builds a fresh root. Releases no storage -- only closing the Vfs does that. |
One file within a root.
| Method | Returns |
|---|---|
String getName() |
The name of the entry relative to the package root, with / as the separator and no leading /, e.g. "com/xyz/Widget.class". For a multi-release entry the META-INF/versions/<version>/ prefix is stripped, so the same entry has the same name whichever version was selected. |
String getPath() |
The full path, which locates the entry on the machine rather than within its root: a filesystem path for a file in a directory, the jarfile path then !/ then the entry's name for a jarfile entry, and the module name then : then the entry's name for a module resource. |
URI getURI()URL getURL()
|
The location of the entry: a file: URI in a directory, a jar: URI in a jarfile, and whatever the module names it as for a module resource, which is a jrt: URI for a module of the running JDK. |
VfsRoot getRoot() |
The root this entry is in. |
long getLength() |
The size of the content once decompressed, or -1 if that is not known without reading the entry, which is the case for a module resource. |
long getCompressedSize() |
The number of bytes the entry occupies in its root, i.e. its size after compression, or -1 if that is not known. The same as getLength() for an entry that is not stored compressed. |
long getLastModifiedTimeMillis() |
The last modified time in milliseconds since the epoch, or 0 if the root does not record one, which is the case for a module resource. |
Set<PosixFilePermission> getPosixFilePermissions() |
The permissions, or null if the root does not record them -- which is the case for a module resource, a jarfile written without them, and a filesystem that does not support them. |
InputStream open() |
The content, as a stream, decompressed on the fly. You own it and must close it. |
ReadableByteChannel openChannel() |
The content, as a channel. You own it and must close it. |
CloseableByteBuffer read() |
The whole content, as a read-only ByteBuffer. You own it and must close it: it may be a memory mapping, or may belong to the module reader that produced it. Throws OutOfMemoryError if the entry is larger than 2GB, which is the largest a ByteBuffer can be. |
byte[] load() |
The whole content, as your own copy, which stays valid after the Vfs is closed. |
String loadAsString()String loadAsString(Charset)
|
The whole content, decoded as UTF-8, or as the given charset. |
Path asPath() |
This entry as a Path in the root's asFileSystem() view. Never null, whatever kind of storage the entry is held in. |
Path getNioPath() |
The entry's Path in the filesystem, or null if it is not a file this JVM has mounted, which is the case for a jarfile entry and a module resource. |
VfsEntry has one further public method, getZipEntry(), which returns a type from the library's internal packages. Those packages are exported only to ClassGraph's own modules, so the method cannot be called from a module of your own, and it is not covered by the API compatibility guarantees.
CloseableByteBuffer is AutoCloseable, and getByteBuffer() returns the buffer:
try (Vfs vfs = new Vfs();
VfsRoot root = vfs.open("/path/to/library.jar")) {
VfsEntry entry = root.getEntry("com/xyz/Widget.class");
try (CloseableByteBuffer buf = entry.read()) {
ByteBuffer byteBuffer = buf.getByteBuffer();
// ...
}
}Iterating a root, or calling getEntries(), builds a list of every file in it. When only some of them are wanted, walk() is cheaper: it offers each directory before the entries in it, so an unwanted one can be skipped, and for a directory tree a skipped directory is never even listed. When the wanted entries all sit under one path, getEntries(prefix) does exactly this for you.
try (Vfs vfs = new Vfs();
VfsRoot root = vfs.open("/path/to/classes")) {
root.walk(new VfsVisitor() {
@Override
public boolean enterDirectory(String dirName) {
// Return false to skip this directory
return !dirName.startsWith("com/xyz/test/");
}
@Override
public boolean visitEntry(VfsEntry entry) {
System.out.println(entry.getName());
// Return false to stop the walk early
return true;
}
});
}How much a skipped directory skips differs by root, and the difference matters: a directory tree skips the whole subtree, because not listing it is the entire saving, whereas a jarfile or a module already has its entry list in hand, so only that directory's own entries are skipped and the directories below it are still offered. That is deliberate -- a caller that strips a package root prefix such as BOOT-INF/classes/ from the names before judging them would otherwise prune BOOT-INF/ and lose everything under it.
A Vfs owns storage: the open file handles, the memory mappings, and the temporary files that a nested jarfile has to be spilled to when it cannot be read in place. Closing the Vfs releases all of them and closes every root it handed out, so a Vfs belongs in a try-with-resources block, and no VfsRoot, VfsEntry, InputStream or ByteBuffer obtained from it should be allowed to escape that block. The one exception is entry.load(), which returns your own copy of the content and stays valid afterwards.
A VfsRoot is AutoCloseable too, but closing one only drops that root: its entries stop working, its FileSystem view starts throwing ClosedFileSystemException, and opening the same path again builds a fresh root. The jarfile behind it stays open, because other roots may be reading the same jarfile. Only the Vfs releases storage.
So a root does not have to be closed, and there is nothing to leak by not closing one -- it is worth closing when a long-lived Vfs opens many roots, since a Vfs holds every root it has opened until it is closed itself. An IDE cannot know that, though: with resource analysis switched on, which is the default in Eclipse, VfsRoot root = vfs.open(path) is reported as "Potential resource leak: 'root' may not be closed", because vfs.open() returns an AutoCloseable. Declaring the root in the try-with-resources silences it and costs nothing, and every example on this page does that. Where a root is opened and used without being named -- vfs.open(path).getEntries() -- the same warning is reported against the unnamed value, and can be ignored.
Reading anything after the Vfs has been closed throws IOException rather than returning wrong bytes.
A Vfs and everything it hands out is safe to use from many threads at once. Two threads that open the same path get the same VfsRoot, and only one of them does the work of reading it. This is what lets ClassGraph scan a jarfile in parallel.
That matters more than it might sound, because java.util.zip.ZipFile serializes on the instance monitor: every public method takes synchronized (this), and so does every read call on the streams it hands out. classgraph-vfs never has a cursor to protect. Every read names the absolute offset it wants: a file is read either through a memory-mapped ByteBuffer, which is indexed absolutely, or through FileChannel#read(ByteBuffer, long), which takes the position as an argument rather than carrying one, and which reaches pread without taking the channel's position lock. The central directory is parsed once into an immutable index. So entry lookup and entry reads take no locks at all. Entry lookup under ZipFile does not merely fail to speed up with more threads, it gets slower; see the measurements in the classgraph-vfs README.
Iterating a root, getEntries() and walk() report files, not directories, and skip anything that cannot be read as a file:
-
Directory entries, i.e. zip entries whose name ends in
/. - Encrypted entries.
- Entries stored with a compression method other than stored or deflated, which are the two the JDK's inflater supports.
- Entries whose central directory record is inconsistent -- a compressed or uncompressed size, an offset, or a local header position that falls outside the file.
Open the Vfs with verbose() to see which entries were skipped and why.
One thing a directory root does still report is a file the process has no permission to read. Telling the files from the subdirectories uses the metadata the walk already reads, and a permission check would cost a second syscall per file, so the walk does not make one. Reading such an entry throws an IOException, and getEntry() returns null for its name -- so a name that iterating the root reported can still come back null from getEntry().