“Every truth has two sides; it is as well to look at both, before we commit ourselves to either.”
Aesop
An application wants to read a file. However, the file is not located on a hard drive but exists as an artifact in an OCI registry. Does the application therefore need to know how to resolve manifests, query tags, and download blobs?
This is exactly where the ORAS filesystem comes in: It translates the world of OCI artifacts into the familiar interfaces of Java NIO. From a developer’s perspective, the boundary between these two worlds is particularly interesting. A path can be constructed relatively easily. Mapping a remote, content-addressed artifact in such a way that file access remains meaningful requires significantly more decision-making.
Why consider a registry as a file system in the first place?
OCI registries can store more than just container images. For example, ORAS can be used to distribute archives, configurations, or other binary artifacts. For many applications, the content of such an artifact is initially just a sequence of bytes.
Java already provides a useful set of tools for this access through Path, Files, and the Channel interfaces. If a library accepts a Path or a URI, an additional proprietary download client is often unnecessary. Instead, the registry connection can be hidden behind a FileSystemProvider.
This means that accessing a registry, for example, becomes:
URI uri = URI.create("oras://registry.example.com/myorg/myrepo/manifests/latest");
byte[] content = Files.readAllBytes(Path.of(uri));
The file system is opened automatically upon the first URI-based path access. Application code does not need to create a registry client, nor does it need to manually translate a tag into a digest.
For applications that read resources via URLs, there is a second entry point:
try (InputStream stream = uri.toURL().openStream()) {
// Process the artifact content just like any other stream resource.
}
Both entry points are discovered via Java ServiceLoader registrations. This requires that the library, along with its dependencies, be available at runtime and that the application accept the oras: schema. In the case of a combined Uber JAR, the entries under META-INF/services must be preserved or merged.
The most important design decision: What constitutes a file here?
An OCI registry does not contain ordinary files or directories. Its core components are repositories, manifests, tags, and blobs. The file system uses these to create a virtual hierarchy:
/ /registry.example.com/ /registry.example.com/myorg/myrepo/manifests/latest /registry.example.com/myorg/myrepo/manifests/sha256:... /registry.example.com/myorg/myrepo/blobs/sha256:... /registry.example.com/myorg/myrepo/tags/latest
The three directories in the repository serve different purposes:
- The
manifests/directory contains digests for regular files and tags for symbolic links to these digest entries. When read, the file returns the contents of the first layer, not the manifest’s JSON document. - In the
blobs/directory, the raw blob contents can be read directly via their digest. - Under
tags/, a tag is represented as a virtual text file. Its content is the manifest digest followed by a line break.
The distinction between manifest and blob digests is important: A manifest describes the artifact and its layers. Its digest is not automatically the digest of the downloaded content.
Mapping tags as symbolic links is a clear way to visualize this. A tag is a movable reference; a digest identifies specific content. Files.readSymbolicLink() can therefore return the absolute path to the manifest digest. Creating a new symbolic link under manifests/ corresponds to publishing another tag for an existing manifest.
This structure is a deliberate selected view of the registry. It is not a directory tree that is stored there in exactly this form.
Separate the display name and registry endpoint
With a local test registry, the virtual path usually starts with something like /localhost:5000. You can use the hostname option to assign a human-readable name to it:
Map<String, Object> env = Map.of(
"hostname", "development",
"insecure", true,
"plain-http", true
);
try (FileSystem fs = FileSystems.newFileSystem(URI.create("oras://localhost:5000"), env)) {
Path artifact = fs.getPath("/development/myorg/myrepo/manifests/latest");
// The URI still contains the actual registry endpoint.
// oras://localhost:5000/myorg/myrepo/manifests/latest
URI resource = artifact.toUri();
}
The name replaces only the first virtual path segment. Network access and the identity of the file system continue to be determined by the host and port specified in the creation URI. Therefore, different display names do not allow multiple file systems to be open simultaneously for the same endpoint within the same provider.
An Overview of the Implementation
The library relies on Java 21 and the ORAS Java SDK. The library’s own code focuses on the NIO implementation; the SDK handles registry communication.
| Class | Responsibility |
|---|---|
OrasFileSystemProvider | Introduction to the oras schema, file system management, and translating file operations into registry operations. |
OrasFileSystem | Contains the registry client, configuration, manifest cache, and registered open channels. Provides paths and file system metadata. |
OrasPath | Immutable path representation using segments. Implements structural operations such as resolve, relativize, and normalize, as well as URI conversion. |
OrasBlobReadChannel / OrasBlobWriteChannel | Reading from blob streams and buffered writing with publication on close. |
OrasDirectoryStream | A filterable iterator over a list of virtual paths that has already been determined by the provider. |
| Node-Typen und Dateiattribute | Model directories, regular files, and symbolic links, and map them to NIO’s basic attributes. |
OrasManifestCache | A Caffeine-based cache for successfully resolved manifests with configurable lifetime and size. |
OrasURLStreamHandlerProvider | Links URL read operations to the installed NIO provider so that both access methods use the same file systems. |
The Journey from the Path to the Content
When accessing a URI, Java first searches for the appropriate NIO provider. This provider determines the registry endpoint and either uses an already open file system or creates one. A ConcurrentHashMap with atomic calculations per key prevents parallel initial path accesses from creating multiple file systems for the same host.
The actual read operation breaks down the virtual path into registry, repository, scope, and entry. For a manifest tag, the digest must first be determined. The manifest is then loaded, the first layer is selected, and its blob is made available via a read channel.
The network stream is not opened until reading begins. This enables sequential access without necessarily keeping the entire blob in memory. Of course, Files.readAllBytes() still collects all the bytes; anyone processing large artifacts should use a stream or channel.
The URL handler is intentionally minimal: it translates the URL into an NIO path and opens a read stream via Files.newInputStream(). Registry resolution and file semantics thus remain implemented in a single location.
Writing means publishing
Writing is possible under manifests/{tag}. When the write channel is closed, the collected content is uploaded as a blob. An empty OCI configuration and a minimal manifest that references the layer are also included. Finally, the manifest is published under the desired tag.
URI target = URI.create("oras://registry.example.com/myorg/myrepo/manifests/v1");
Files.copy(Path.of("artifact.zip"), Path.of(target));
The timing of the close operation is technically significant here: Publication does not begin until then. A successful write() call alone does not mean that the artifact is in the registry. Accordingly, an upload error can occur during the close operation.
Furthermore, the individual registry calls do not form a transaction. If publishing the manifest fails after the blob upload, blobs that have already been uploaded may remain. Moving a blob is also implemented as a copy followed by a delete and does not provide atomic renaming.
Configuration and Service Life
To get started without initialization code, the automatic generation process uses default values and the ORAS SDK’s credential resolution. Options can be set as JVM system properties:
java -Doras.insecure=true -Doras.plain-http=true -jar application.jar
Other properties include, for example, oras.username, oras.password, oras.hostname, oras.cache.ttl-seconds, and oras.cache.max-size. These values apply to filesystems created automatically. A filesystem that is already open retains its configuration.
If you need different settings per registry, explicitly open the file systems with an environment map before the first access. The automatic option is convenient but shifts the responsibility for resource management: The provider maintains the file system until it is explicitly closed or until the JVM terminates. Closing a URL stream only terminates the stream.
An existing file system can be closed using FileSystems.getFileSystem(uri).close(). Afterward, old paths still belong to the closed file system; a new URI-based path access creates a new instance.
What Abstraction Gains
Less specialized knowledge in the application code
Many readers are only interested in bytes. Parsers, importers, or configuration loaders do not need to handle OCI references and SDK calls themselves. An application with freely configurable resource URIs can often be integrated simply by including the library and replacing the URI.
Familiar Tools and Interfaces
Path operations, streams, directory filters, and basic attributes are familiar Java concepts. This lowers the barrier to entry and makes it easier to work with libraries that already use NIO. The additional URL handler also supports programs that do not offer a path interface but open streams from URLs.
Reuse of the Registry Infrastructure
Existing repositories, authentication, and artifact distribution can continue to be used. This is appealing for versioned archives or configuration packages: The storage location changes, while the code that consumes them can remain largely the same. The registry being used continues to determine which permissions and guarantees actually apply.
Explicit Versions via Digests
The virtual hierarchy highlights the difference between a dynamic tag and a digest reference. If you need reproducible inputs, you can use the manifest digest instead of a tag like latest. This does not protect against missing or deleted artifacts, but it avoids the ambiguity of a tag reference that is later redefined.
Where the File System Metaphor Reaches Its Limits
Familiar API, Different Costs
A single Files call can trigger multiple HTTP requests. Directory listings are determined in advance, and listing manifests or blobs may require additional requests per day. A DirectoryStream of this kind is therefore not a free, incremental view of a local folder.
Even SeekableByteChannel does not automatically mean fast random access. The read channel operates on a forward-only blob stream; moving backward requires reopening the channel. Optimized range-request access is not available in this implementation. For access-intensive binary formats, a local temporary file may be a better option.
The heap limits uploads
The write channel buffers the entire contents in memory. Additional byte arrays are created during publishing and partial overwrites. The upload path for local files also initially reads their entire contents. This is easy to implement but is not ideal for large artifacts and many parallel uploads.
Furthermore, in the current implementation, no content is published upon closing if the write buffer is empty. Anyone who needs empty files as regular artifacts must take this limitation into account. For large uploads, a temporary-file-based or true streaming write path would be an obvious next step.
An artifact can be more than just a file
Accessing the manifests/ directory provides only the first layer. A multi-layered artifact does not automatically become a complete package or an extracted directory tree as a result. Blob paths are available for additional layers; users who wish to comprehensively edit manifests, annotations, or specific artifact media types are better served by the ORAS SDK.
Writing also generates a simple single-layer artifact with a generic media type. NIO file access does not, by itself, convey all the technical metadata of an OCI artifact.
Directories and metadata are virtual
Repositories are created through publications, not through actual directory objects. The hierarchy is therefore not a complete registry catalog that can be traversed arbitrarily from the root like a local hard drive.
Basic attributes are supported. POSIX permissions, user management, and a WatchService are missing. Timestamps are represented using the epoch value, and storage space queries return Long.MAX_VALUE instead of actual registry capacities. Such values serve as placeholders for the interface and do not provide reliable information about the server.
A cache does not guarantee consistency
The manifest cache reduces repeated queries. By default, entries are retained for up to five minutes, and a maximum of 1,000 manifests are stored. Failed manifest lookups are not cached as negative entries.
Changes made by other clients may still not become visible until the cache lifetime has expired. Furthermore, invalidation following one’s own write operations is something that must be checked on a per-access-path basis: The local upload path invalidates the target entry, whereas the direct write channel itself has no connection to the manifest cache. A general guarantee of immediate up-to-date status would therefore be too broad.
Not every application can be migrated using only a URI
new File(uri) does not accept just any file system provider. Similarly, an ORAS path cannot be meaningfully converted into a local file using toFile(). Applications with a hard-coded file: schema or that require access to java.io.File need to be modified or require a local download beforehand.
The provider registers the oras: schema. It neither replaces the built-in file: provider nor provides an operating system mount that arbitrary native programs could access.
A brief digression: AI for the boring basics
Building your own NIO provider involves a lot of mechanical work. Path requires numerous methods, plus filesystem metadata, channel states, iterators, and ServiceLoader files. Many of these tasks are important, but they’re rarely the most interesting part of the project.
This is where AI can help as a tool: an initial SPI framework, recurring delegations, and structural path operations can be generated quickly. This frees up time for the real questions: What does a tag represent? Which bytes does a manifest path return? When is an upload published? Which expectations of a normal file can we fulfill?
Especially with a file system abstraction, however, one must not confuse the generated framework with a finished implementation. A method that looks plausible can violate a contract when dealing with relative paths, closed channels, backward jumps, or parallel creation. Even a convincing Javadoc comment does not prove that the code actually upholds the described guarantee.
A sensible division of labor is therefore:
- Consciously define the technical scope and its limits.
- Use AI for repetitive interface work and initial implementation drafts.
- Check critical semantics in the code and validate them with meaningful tests.
- Measure documentation against the actual implementation.
For this project, integration tests are running against a Zot registry in TestContainers. Among other things, these tests verify uploads, read access, tags, symlinks, and access via NIO and URL. Additionally, automatic creation, reuse, and parallel initial path accesses are tested. This allows for the verification of real SDK and registry interactions; however, it does not replace a complete NIO conformance check.
The productive use of AI lies here in shortening the path to a verifiable design. Responsibility for file semantics remains with the developers.
Conclusion: A Bridge for Artifacts
The ORAS file system is particularly useful for applications that primarily read versioned artifacts sequentially and already use NIO paths or URL streams. It makes registry contents accessible via familiar Java interfaces and keeps OCI-specific code out of many consumer applications.
However, its usefulness depends on the abstraction matching the access pattern. Large uploads, intensive random access, extensive OCI metadata, or expectations of a full-fledged local file system require additional work or more direct access via the SDK.
From a developer’s perspective, that’s precisely the appeal: the interfaces are familiar, but the semantics must be renegotiated. A good provider hides the necessary translation without omitting the characteristics of the remote storage from the documentation.
Additional examples and the available options can be found in the project README.