Sometimes an extension starts with a question that sounds unremarkable at first: Can I use the files from an OCI-based file system in a FreshMarker template?
FreshMarker File already supports java.nio.file.Path. An OCI file system like Oras Filesystem also returns Path objects. So, in theory, the integration should work. However, during practical testing, I discovered an assumption in my code that works for local paths but not for other file systems: I had converted a Path back to a java.io.File in several places.
This investigation led to a general extension of FreshMarker File. The existing Path operations now use Java NIO, and templates can additionally list directories and examine symbolic links.
The Hidden Dependency on the Local File System
The previous implementation of some built-ins essentially boiled down to this code:
Path path = templatePath.getValue(); boolean exists = path.toFile().exists();
With a local path, this isn’t immediately apparent. However, the conversion is not available for ZIP or ORAS paths. Path.toFile() is not a universal bridge between Java NIO and java.io.File. The provider may throw an UnsupportedOperationException if the path cannot be represented by a local file.
The first ORAS test made this difference very clear: the name, parent path, and checksums were already working. However, existence, file type, access checks, and size failed due to toFile(). The checksums had already been implemented via NIO, while the metadata was still handled via the older File API.
My goal, therefore, was not to implement special handling for OCI. I wanted to implement the existing path support in such a way that it actually leverages the abstraction provided by Java NIO.
Let the Provider Do the Work
The key change is small, but its impact is significant:
boolean exists = Files.exists(path); boolean directory = Files.isDirectory(path); boolean file = Files.isRegularFile(path); long size = Files.size(path);
These methods delegate to the provider of the respective path. This means FreshMarker File does not need to know how a ZIP archive is organized or how a registry provides its contents.
The same change applies to can_read, can_write, and can_execute. name and parent_path remain structural operations on the path itself. I now explicitly return the name as a string, while the parent path is still returned as a path. This ensures that concatenations like ${entry?parent?is_directory} remain possible.
${path.size} and ${path?size} now also use the same helper method. This prevents attribute access and built-in functions from behaving differently on a third-party file system. For root paths, the name is an empty string; a missing parent path is represented as a NULL value.
Support for java.io.File remains unchanged. No new dependency on a specific file system provider has been added for production code.
Compatibility Requires a Deliberate Error Semantics
Replacing File.length() directly with Files.size() changes the behavior when files are missing. The File API returns 0 in this case, while NIO throws a NoSuchFileException.
For FreshMarker File, I have retained the previous handling of missing paths: The size query continues to return the value 0. Additionally, there is a length query that throws an exception if paths are missing. Other errors are forwarded as a ProcessException along with the operation, the path, and the original cause.
This is particularly helpful with remote file systems. A nonexistent file, a lack of access, and a network problem are distinct situations.
For boolean checks, the NIO semantics still apply. The value false can mean that a property does not apply or could not be determined. The built-in can_write is also a snapshot and not a guarantee that a subsequent write operation will succeed. Directory sizes and unknown size values remain dependent on the provider.
Listing Directories in a Template
With this change, the existing built-in functions now also work for ZIP directories. However, for practical use, I still needed one more operation: to make the direct entries of a directory available as template values.
There is now a children function for this:
<#list directory?children as entry>
${entry?name}: ${entry?is_directory}
</#list>
Internally, I use Files.list() and collect the results as a list of Path objects. The stream is closed within the operation before the list is returned to FreshMarker. This way, the template doesn’t have to manage any open directory resources.
The returned paths retain their provider. An entry from a ZIP archive therefore remains a ZIP path and can subsequently be processed using size, , length, or parentchecksum.
The built-in children lists only the direct directory level. The order is determined by the provider; fixed sorting or recursive traversal is not part of the operation.
Identifying Links and Displaying Their Destinations
Symbolic links deserve special consideration. For size or checksums, it usually makes sense to follow the link. However, when viewing a directory listing, I also want to be able to tell that an entry is a link at all.
That’s why I’ve added four built-in functions:
| Built-in | Meaning |
|---|---|
is_symbolic_link | Checks the entry itself without looking at the link’s destination. |
exists_no_follow | Checks for existence without resolving the link and can therefore also detect a link with a missing destination. |
link_target | Returns the link destination as a Path; relative destinations remain relative. |
real_path | Returns the existing, resolved path via Path.toRealPath(). |
This allows you to create a directory overview:
<#list directory?children as entry>
${entry?name}
<#if entry?is_symbolic_link>
-> ${entry?link_target}
</#if>
</#list>
Existing content and type operations continue to follow links. The name and parent path refer to the original entry. Calling link_target on a regular file or on a provider that does not support links results in a clear processing error.
The new built-ins also work with File values. Path results, such as link targets or directory entries, are returned as NIO paths.
Why ZIP Is the Right Test Example
ORAS was the reason for the change. However, for the automated regression tests, I wanted a foundation that didn’t rely on the registry or login credentials. The JDK’s ZIP file system serves this purpose perfectly.
It is a truly different file system provider and does not support the problematic conversion via toFile(). As a result, the tests precisely expose the assumption that was incorrect in the original implementation.
The tests generate temporary ZIP archives containing small files, an empty file, binary data, and directories. Among other things, they check:
- Interpolation, names, parent paths, and root paths,
- existence, file types, and sizes,
- access checks based on provider capabilities,
- directory listings and further processing of their entries,
- missing paths and unsupported operations,
- checksums, and access after the file system has been closed.
Here’s an example of how it’s used:
try (FileSystem zip = FileSystems.newFileSystem(Path.of("documents.zip"))) {
Path directory = zip.getPath("/documents");
Template template = new Configuration().builder()
.getTemplate("listing", """
<#list directory?children as entry>
${entry?name}: ${entry?size} bytes
</#list>
""");
String result = template.process(Map.of("directory", directory));
}
The file system remains open during processing. Although a list of Path objects no longer keeps a directory stream open, it still requires its provider and the provider’s open file system for subsequent content and metadata operations.
The ZIP file system does not support standard NIO symlinks. The ZIP tests therefore also check the system’s response to unsupported link operations. Positive link tests are additionally run on the local file system, provided the platform allows the creation of links.
Checksums: Two Small but Important Corrections
The checksums were already provider-independent, but while revising the code, I corrected two details.
The read loop now only ends at -1. A temporary read result of 0 no longer causes the calculation to terminate prematurely. A targeted channel test ensures that reading continues afterward and the channel is closed.
For the hex output, I now use HexFormat instead of converting via BigInteger. This preserves leading zeros, and the results have the fixed length of the respective algorithm.
The MD5 checksum of the content a is a good example:
0cc175b9c0f1b6a831c399e269772661
The leading zero is part of the result. It must not be removed simply because the digest was formatted as a number.
For remote file systems, it’s also important to note that a checksum reads the complete bytes provided by the provider for a given path. For large files, this results in a corresponding amount of network traffic.
Result
For me, the most important change isn’t the support for a single registry. FreshMarker File now actually treats a path as a path. ZIP and ORAS demonstrate that the same template operations can work across different providers, as long as those providers offer the appropriate NIO functions.
With children and the link built-ins, more information can also be expressed directly in the template. What started as a minor compatibility issue has thus become a useful extension for local, archive-based, and remote file systems.
These additions will be available soon as part of Freshmarker File 3.1.0.