Skip to content

Latest commit

 

History

History
1400 lines (1139 loc) · 54.9 KB

File metadata and controls

1400 lines (1139 loc) · 54.9 KB

SmallMind File

The smallmind-file module is a container for alternative java.nio.file FileSystem implementations — file systems that plug into the standard JDK NIO SPI (java.nio.file.spi.FileSystemProvider) but store, route, or gate their data in ways the built-in file: provider cannot.

It is organized as two independently publishable artifacts:

  • file-ephemeral — an in-memory, heap-backed FileSystem with a transparent fall-through to a native provider for paths outside its configured roots. Can be installed as the JVM default through -Djava.nio.file.spi.DefaultFileSystemProvider=….

  • file-jailed — a chroot-style FileSystem that confines all path resolution to a subtree of an underlying native file system. The jail boundary can be fixed at construction time or resolved per-call from a thread-bound context.

Neither artifact has a runtime dependency on the other. Both depend only on smallmind-nutsnbolts; the jailed artifact additionally has an optional compile-time dependency on jakarta.xml.bind-api for the JAXB annotations on RootedFileSystemContext.

The JDK ships exactly one production-grade FileSystemProvider, the platform file: provider. Everything else — in-memory sandboxes, chroot confinements, overlay trees for tests, watched virtual directories — either has to be implemented by hand or pulled in from a heavyweight dependency such as Jimfs, Memoryfs, or a full VFS library.

The two modules in smallmind-file are the minimum useful answer to two recurring needs that do not justify either of those choices:

  1. Ephemeral, default-installable in-memory storage with native pass-through. Tests want a Files.createDirectories / Files.write / Files.readString loop that leaves no disk artefacts, runs at memory speed, and still reads real classpath resources through the ordinary file: provider. A production service that wants an overlay — for example, a scratch area under /opt/epicenter/twimble that must behave like a real directory to legacy code but never touch disk — wants the same thing, but installed JVM-wide.

  2. A hard boundary around a subtree of the real file system. Code that accepts user-controlled path inputs — uploaded file managers, sandboxed plugin hosts, multi-tenant storage services — needs a FileSystem whose absolute / is someone else’s /var/lib/service/tenant-42, with every path translation gated so ../../etc/passwd cannot escape. The JDK provides no such type; this module provides two concrete strategies for it.

Both modules exist as ordinary FileSystemProvider implementations so that they integrate through java.nio.file.Files, java.nio.file.Path, and java.nio.file.Paths without any code on the caller’s side knowing that a non-standard provider is in play.

  • Not a full VFS (no zip, no SFTP, no S3). The two provided file systems are the two the repository needs; adding more is possible but is not the purpose of the module.

  • Not a security boundary on its own. The jailed provider enforces path containment at the path translation layer; it does not sign, encrypt, or audit I/O, and it does not defend against kernel-level escapes such as symlink traversal on the underlying native file system. A caller that wants those properties must compose them over the jailed provider.

  • Not a persistence layer. The ephemeral provider is, by construction, lost on JVM exit.

If you are … Read, in this order

… a test author who wants a throwaway in-memory file system for the duration of a single test

InstallationEphemeral quick startConfiguration.

… a service author installing ephemeral storage as the JVM default provider

Installing as the JVM default providerConfigurationNative delegation in detail.

… a library author building a user-facing upload surface that must not escape a tenant directory

Jailed quick startTranslator strategiesDeriving the jail boundary per request.

… someone trying to understand why a call is throwing ProviderMismatchException, SecurityException, or FileSystemAlreadyExistsException

Error Model.

Artifact Ships Reach for it when

file-ephemeral

EphemeralFileSystemProvider, EphemeralFileSystem, EphemeralFileStore, heap tree, watch service

You need a FileSystem whose storage is RAM and whose paths can either stand alone (ephemeral:///…) or masquerade as the JVM default (file:///…) with transparent pass-through for anything outside the configured roots.

file-jailed

JailedFileSystemProvider, JailedFileSystem, JailedPath, JailedPathTranslator strategy tree

You need a FileSystem whose / is actually a subtree of some other native file system, and every path operation must be guarded from escaping that subtree.

A few terms recur throughout this module.

Provider-managed singleton

Both EphemeralFileSystemProvider and JailedFileSystemProvider maintain exactly one FileSystem instance per provider. Calls to newFileSystem(URI, Map) always throw FileSystemAlreadyExistsException; getFileSystem(URI) returns the single pre-constructed instance after validating the URI. This is a deliberate choice: it keeps the mapping between scheme and provider unambiguous when the provider is registered through the SPI.

Scheme mirroring

The ephemeral provider has a no-arg constructor using the scheme "ephemeral" and a EphemeralFileSystemProvider(FileSystemProvider) constructor that mirrors the scheme of another provider (typically the platform file: provider) and captures a reference to that provider’s root file system. When the provider is installed as the JVM default and mirrors the file: scheme, ephemeral paths and native paths can coexist under the same Paths.get(…​) call, with the EphemeralFileSystemConfiguration deciding which ones are "ours".

Native delegation

In the ephemeral provider, any path that does not start with one of the configured roots is wrapped in a NativePath adapter and all operations on it are forwarded, unchanged, to the native provider. This is what makes the ephemeral provider safe to install as the JVM default: real files under C:\Users\… or /usr/… are not intercepted; only paths that match the configured overlay roots are served from the heap.

Path translation

In the jailed provider, every operation goes through a JailedPathTranslator.unwrapPath(Path) call that converts the jailed absolute path into a native path rooted at the jail’s configured root directory. Listing operations wrap the returned native paths back into JailedPath instances via wrapPath(…​), which throws SecurityException if the native path does not start with the configured root.

Heap tree

Internal name for the in-memory data structure that backs the ephemeral file store: a tree of DirectoryNode and FileNode objects, each descending from a single anonymous root. File content lives in a ByteArrayIOBuffer (from smallmind-nutsnbolts) and is exposed to clients through EphemeralSeekableByteChannel.

Heap event / watch translation

Mutations to the heap tree raise HeapEvent objects whose path is the changed entry. Each event bubbles up the heap-node parent chain. An EphemeralHeapEventListener is installed per watched directory and carries that directory’s path. When bubbling reaches the listener, it forwards the event to the EphemeralWatchService, which computes the WatchEvent context() as watchedPath.relativize(changedPath) so that the standard NIO WatchService contract is honoured end-to-end, entirely in heap.

Both artifacts are published under org.smallmind:

<dependency>
  <groupId>org.smallmind</groupId>
  <artifactId>file-ephemeral</artifactId>
  <version>${project.version}</version>
</dependency>

<dependency>
  <groupId>org.smallmind</groupId>
  <artifactId>file-jailed</artifactId>
  <version>${project.version}</version>
</dependency>

Use whichever one you need; depending on both is allowed but unusual.

file-ephemeral

Requires smallmind-nutsnbolts (for ByteArrayIOBuffer, ByteArrayIOStream, SingleItemIterable, SnowflakeId, and UnknownSwitchCaseException). Everything else is JDK-only.

file-jailed

Requires smallmind-nutsnbolts (for ContextFactory / Context). The jakarta.xml.bind-api dependency is marked <optional>true</optional> — it is needed only if you intend to unmarshal RootedFileSystemContext from XML. If you construct contexts programmatically, JAXB need not be on the classpath.

The jailed artifact ships a META-INF service entry:

META-INF/services/java.nio.file.spi.FileSystemProvider
  org.smallmind.file.jailed.JailedFileSystemProvider

so simply placing file-jailed on the classpath registers the jailed: scheme with FileSystems.newFileSystem(URI, Map).

The ephemeral artifact does not ship a service entry. Installation is controlled explicitly: the provider is instantiated either directly by the application or by the JVM via -Djava.nio.file.spi.DefaultFileSystemProvider=… (see Installing as the JVM default provider). This is deliberate — the ephemeral provider’s whole point, when installed as a default, is to replace the platform file: provider, which is a decision an application must make on purpose.

The ephemeral provider is a complete NIO file system backed entirely by heap memory, with built-in pass-through to a native provider for paths the configuration does not claim.

  • Serves a single EphemeralFileSystem instance per provider, with one EphemeralFileStore of configurable total capacity and block size.

  • Recognises a configurable set of path prefixes (roots) and routes any path that matches one of them through the in-memory heap tree.

  • Wraps every other path in a NativePath adapter that delegates to the native provider captured at construction. From the caller’s perspective, Paths.get(…​) returns a seamless Path regardless of whether it resolves to heap or disk.

  • Implements the full SeekableByteChannel, DirectoryStream, and SecureDirectoryStream contracts against the heap, so Files.newByteChannel, Files.newDirectoryStream, and Files.walkFileTree all work.

  • Implements WatchService against heap-bubbled events (HeapEventWatchEvent.Kind translation), so Files.newWatchService() produces a working in-memory watcher.

  • Exposes a clear() method on both EphemeralFileSystem and EphemeralFileStore that drops every node, useful for test teardown.

  • No symbolic link support. EphemeralBasicFileAttributes.isSymbolicLink always returns false; toRealPath is equivalent to normalize().toAbsolutePath().

  • No POSIX permission model. The only FileAttribute accepted by createDirectory and newByteChannel is "posix:permissions", and even that is validated only by name — the value is not stored or enforced.

  • No FileChannel / AsynchronousFileChannel support for ephemeral paths. Those methods exist only to delegate to the native provider when given a NativePath; ephemeral paths throw UnsupportedOperationException.

  • No UserPrincipalLookupService backing store. Lookups return fresh EphemeralUserPrincipal / EphemeralGroupPrincipal instances on every call.

  • Not a security boundary. The ephemeral provider offers no access control of its own; use the jailed provider on top if path-space containment is required.

Class / interface Role

EphemeralFileSystemProvider

Entry point. Manages a single EphemeralFileSystem, routes operations to the heap or to the captured native provider depending on path type, and owns the INITIALIZATION_LATCH that waitForInitialization(long, TimeUnit) exposes.

EphemeralFileSystem

The FileSystem instance. Holds the EphemeralFileStore, the configuration, and the single root EphemeralPath. Its getPath(String, String…​) consults EphemeralFileSystemConfiguration.isOurs(…​) to choose between an EphemeralPath and a NativePath wrapper.

EphemeralFileSystemConfiguration

Immutable configuration carrier: capacity, block size, and roots. The no-arg constructor reads system properties; an explicit constructor accepts the three values directly. isOurs(first, more) performs a character-by-character match of the assembled path against each configured root.

EphemeralFileStore

The heap file store. Implements every mutating and attribute-reading operation exposed by the provider (newByteChannel, createDirectory, delete, copy, move, readAttributes, setAttribute, newDirectoryStream, heap-listener registration, …). All mutating methods are synchronized. Capacity and block size are enforced here.

EphemeralPath

The heap-resident Path. Stores its component names in a String[] plus an absolute flag. Provides the full Path contract including normalize, resolve, relativize, startsWith, endsWith, getFileName, getParent, and toUri.

NativePath

Adapter Path that reports the EphemeralFileSystem as its file system but delegates every structural operation to an underlying native path. The provider tests path instanceof NativePath and forwards such paths to the native provider’s implementation.

EphemeralBasicFileAttributes / EphemeralBasicFileAttributeView

BasicFileAttributes implementation. Tracks three mutable timestamps (creationTime, lastModifiedTime, lastAccessTime) and a Snowflake-generated fileKey. Per-node view instances are vended by EphemeralFileStore.getFileAttributeView(…​).

EphemeralFileStoreAttributeView

Typed attribute view for the file store. Attribute values are read by reflection against declared fields (see EphemeralFileStore.getAttribute(String)).

EphemeralDirectoryStream

SecureDirectoryStream<Path> returned by EphemeralFileStore.newDirectoryStream(…​). Relative paths passed to its secure-stream methods resolve against the stream’s own path; operations after close() throw ClosedDirectoryStreamException.

EphemeralSeekableByteChannel

Channel backed by the ByteArrayIOBuffer of a FileNode. Single-mode (read xor write) and synchronized. Honours DELETE_ON_CLOSE by removing the file from the store upon close().

EphemeralUserPrincipal / EphemeralGroupPrincipal / EphemeralUserPrincipalLookupService

Trivial UserPrincipal / GroupPrincipal implementations. No external backing store; lookups manufacture fresh instances.

Glob / RegexPathMatcher

Glob-to-regex translator and the compiled-regex-backed PathMatcher. Drives EphemeralFileSystem.getPathMatcher(syntaxAndPattern) for the "glob:" and "regex:" syntaxes.

EphemeralURIUtility

URI validation (checkUri) and URI-to-path conversion (fromUri) helpers shared by the provider.

The heap subpackage, org.smallmind.file.ephemeral.heap, owns the internal data structures:

Class Role

HeapNode

Abstract base for tree nodes. Holds the parent reference, the node name, the EphemeralBasicFileAttributes, and a lazy listener list. bubble(HeapEvent) delivers the event to locally registered listeners and propagates to the parent.

DirectoryNode

Map of child names to child nodes. size() is the recursive sum of child sizes (no cache). All child-mutating methods are synchronized on this node.

FileNode

Leaf node holding a ByteArrayIOBuffer for content. size() returns the buffer’s current limit bookmark.

HeapNodeType

Enum with two constants, FILE and DIRECTORY.

HeapEvent

EventObject carrying an EphemeralPath and a HeapEventType.

HeapEventListener

Callback interface invoked during HeapNode.bubble(…​).

HeapEventType

Enum whose three constants (CREATE, DELETE, MODIFY) each wrap the matching StandardWatchEventKinds.ENTRY_* constant. The watch-service layer reads .getKind() on this enum to produce a standard WatchEvent.Kind.

The watch subpackage, org.smallmind.file.ephemeral.watch, ties the heap events back to NIO’s WatchService:

Class Role

EphemeralWatchService

WatchService implementation. Keeps a path → List<EphemeralWatchKey> map and a LinkedBlockingQueue of signalled keys. The first key registered against a path causes a fresh EphemeralHeapEventListener bound to that watched path to be installed on the path’s directory node; the last key cancelled removes it. fire(watchedPath, kind, changedPath) relativizes the changed path against the watched path to produce the WatchEvent context. Polls in 500 ms increments when timeout >= 500 ms so that closure mid-wait is detected promptly.

EphemeralWatchKey

WatchKey implementation with an internal LinkedBlockingQueue<WatchEvent<?>> of pending events. Tracks valid and signalled flags; reset() re-queues the key with the service if new events arrived while it was being processed.

EphemeralWatchEvent

WatchEvent<?> record: kind, count, context.

EphemeralHeapEventListener

Bridge from HeapEvent to EphemeralWatchService.fire(…​). Each instance is created with the path it was registered on; on every bubbled event it forwards its own watched path, the heap event’s type translated to a WatchEvent.Kind, and the heap event’s changed path so the service can produce the relative context.

EphemeralFileSystemConfiguration has two constructors:

// reads system properties; falls back to defaults
public EphemeralFileSystemConfiguration ();

// explicit
public EphemeralFileSystemConfiguration (long capacity,
                                         int blockSize,
                                         String... roots);

The three knobs are:

Knob Default Notes

capacity

Long.MAX_VALUE

Reported through EphemeralFileStore.getUsableSpace() and getTotalSpace(). getUnallocatedSpace() returns capacity - rootNode.size(), so capacity is a logical ceiling — actual heap consumption is driven by the ByteArrayIOBuffer contents of each FileNode.

blockSize

1024

Passed to the ByteArrayIOBuffer constructor when a new FileNode is allocated. Acts as the initial buffer capacity; the buffer grows past this figure as writes proceed.

roots

{"/"}

The set of prefixes that route a path through the heap. A root must start with "/". Paths that do not match any root fall through to the captured native provider.

The no-arg constructor reads:

org.smallmind.file.ephemeral.configuration.capacity  = <long>
org.smallmind.file.ephemeral.configuration.blockSize = <int>
org.smallmind.file.ephemeral.configuration.roots     = <comma-separated
                                                         list, optionally
                                                         bracketed>

For roots, both "/opt/tmp,/var/scratch" and "[/opt/tmp, /var/scratch]" are accepted; entries that do not start with "/" are prefixed with one automatically.

To replace the platform file: provider:

-Djava.nio.file.spi.DefaultFileSystemProvider=org.smallmind.file.ephemeral.EphemeralFileSystemProvider

# plus any of:
-Dorg.smallmind.file.ephemeral.configuration.capacity=1073741824
-Dorg.smallmind.file.ephemeral.configuration.blockSize=4096
-Dorg.smallmind.file.ephemeral.configuration.roots=/opt/overlay,/var/scratch

When the JVM bootstraps the default file system through this property, it calls the EphemeralFileSystemProvider(FileSystemProvider) constructor, passing the platform file: provider. The resulting provider:

  • reports getScheme() == "file",

  • answers isDefault() == true,

  • captures the platform provider’s root file system in its nativeFileSystem field, and

  • routes every non-matching path through that captured file system.

From the application’s perspective, Paths.get("/opt/overlay/foo") is an EphemeralPath while Paths.get("/etc/hosts") is a NativePath wrapping the platform provider’s path — both returned by the same FileSystems.getDefault().getPath(…​) call.

Note

EphemeralFileSystemProvider.waitForInitialization(long, TimeUnit) is available because, in a default-provider scenario, multiple threads may call Paths.get(…​) before the provider’s constructor has finished. The constructor counts down an internal latch after building the EphemeralFileSystem; waitForInitialization blocks on that latch.

The test in the source tree shows the smallest realistic loop:

import java.nio.ByteBuffer;
import java.nio.channels.SeekableByteChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import org.smallmind.file.ephemeral.EphemeralFileSystem;
import org.smallmind.file.ephemeral.EphemeralFileSystemProvider;

public class Demo {

  public static void main (String[] args)
    throws Exception {

    EphemeralFileSystemProvider.waitForInitialization(30, TimeUnit.SECONDS);  (1)

    Path real = Paths.get("C:\\Users\\david\\Documents\\response.txt");      (2)
    System.out.println(Files.isRegularFile(real));
    System.out.println(Files.readString(real, StandardCharsets.UTF_8));

    Path eph = Paths.get("/opt/epicenter/twimble/farkle");                    (3)
    Files.createDirectories(eph);
    try (SeekableByteChannel bc = Files.newByteChannel(
           eph.resolve("sparkle.txt"),
           Set.of(StandardOpenOption.WRITE, StandardOpenOption.CREATE))) {
      ByteBuffer bb = ByteBuffer.allocate(1024);
      bb.put("Hello out there!".getBytes(StandardCharsets.UTF_8));
      bb.flip();
      bc.write(bb);
    }
    System.out.println(new String(Files.readAllBytes(eph.resolve("sparkle.txt"))));

    ((EphemeralFileSystem)FileSystems.getDefault()).clear();                   (4)
  }
}
  1. Blocks until the default-provider constructor has built the heap file system. Safe to call even when you are not using the default-provider install; it simply returns immediately.

  2. A path that does not match any configured root. Wrapped in a NativePath and forwarded to the real platform provider.

  3. A path under a configured root (or under "/" by default). Served entirely from heap. Notice that from `Files’ point of view the two paths are indistinguishable.

  4. Drops every node. Useful at the end of a test, or between test methods, to reset the store to an empty state.

The ephemeral provider is perfectly usable as a secondary file system. Construct one directly and obtain paths through its getPath(…​):

import java.net.URI;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import org.smallmind.file.ephemeral.EphemeralFileSystemProvider;

EphemeralFileSystemProvider provider = new EphemeralFileSystemProvider("overlay");
FileSystem fs = provider.getFileSystem(URI.create("overlay:///"));

Path p = fs.getPath("/scratch/demo.txt");
Files.createDirectories(p.getParent());
Files.writeString(p, "hello");

The scheme ("overlay" here) is decoupled from "file", so nothing touches the default provider and the two can coexist in the same JVM.

Note that when constructed via the no-arg or String-scheme constructor the provider has no native file system captured, so any path that does not match a configured root will cause a NullPointerException the moment the provider tries to delegate. Mix-mode usage requires the EphemeralFileSystemProvider(FileSystemProvider) form.

The watch service is a full NIO WatchService:

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.WatchEvent;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;

import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE;
import static java.nio.file.StandardWatchEventKinds.ENTRY_DELETE;
import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY;

public void watch (Path root)
  throws Exception {

  Files.createDirectories(root);

  try (WatchService watcher = root.getFileSystem().newWatchService()) {

    root.register(watcher, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY);   (1)

    // ... in another thread ...
    Files.writeString(root.resolve("new.txt"), "hello");                (2)

    WatchKey key = watcher.take();                                       (3)
    for (WatchEvent<?> event : key.pollEvents()) {
      System.out.println(event.kind() + " " + event.count());
    }
    key.reset();                                                         (4)
  }
}
  1. Registration creates a fresh EphemeralHeapEventListener bound to the watched path and attaches it to the directory’s DirectoryNode. The first registration per path attaches the listener; the last cancel detaches it.

  2. Any mutation whose HeapEvent bubbles through the watched directory fires the listener, which forwards the event to the service together with the changed path. The service translates the event type to a WatchEvent.Kind (ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY) and dispatches to the registered keys with watchedPath.relativize(changedPath) as the context.

  3. take() polls the service’s ready queue in 500 ms increments so that a mid-wait close() raises ClosedWatchServiceException promptly rather than hanging.

  4. Forget to call reset() and the key will never be signalled again. If events arrived while the key was being processed, reset() requeues it immediately so it is returned by the next take().

Whenever a Path reaches any provider method, the provider performs three checks in this order:

  1. The path’s file system must be an EphemeralFileSystem. If not, a ProviderMismatchException is thrown with an explanatory message. This is the common failure mode when a caller mixes paths obtained from different providers.

  2. If the path is a NativePath, the operation is forwarded verbatim to the native provider’s implementation of the same operation, using the wrapped native path. Return values (including directory stream iterators and attribute views) come back from the native provider unchanged; they are not re-wrapped as ephemeral types.

  3. Otherwise the path is treated as an EphemeralPath, normalised, and passed to the EphemeralFileStore for the actual operation.

For two-argument operations (copy, move, isSameFile) both paths must be associated with an EphemeralFileSystem; otherwise ProviderMismatchException is raised. Cross-provider copies (ephemeral → native and vice versa) must be performed through Files.copy(InputStream, Path) / Files.copy(Path, OutputStream) rather than Files.copy(Path, Path).

The NativePath adapter is the seam that makes default-provider installation feel transparent. NativePath.getFileSystem() reports the ephemeral file system so that NIO type checks pass, but every structural operation (getRoot, resolve, normalize, toUri, compareTo, startsWith, endsWith, etc.) delegates to the wrapped native path.

The jailed provider takes an existing native file system and serves it to callers as if its root were some other configured directory on that file system. Absolute paths under the jail look like "/foo/bar"; the provider translates them to <configured-root>/foo/bar on the native side before delegating.

  • Serves a single JailedFileSystem instance per provider under the jailed: URI scheme by default (or any scheme supplied explicitly).

  • Translates every path supplied to any provider method into a native path via a JailedPathTranslator, and forwards the operation to the native provider.

  • For listing operations, wraps returned native paths back into JailedPath instances via translator.wrapPath(…​), which throws SecurityException if the native path escapes the jail.

  • Exposes two translator strategies out of the box: RootedPathTranslator (fixed native root) and ContextSensitiveRootedPathTranslator (native root read from a thread-bound RootedFileSystemContext).

  • No native-to-jail path re-entry guard beyond startsWith(rootPath). Symbolic links on the underlying native file system that point outside the root are not detected; the caller is responsible for choosing a root whose contents do not include escape-capable links.

  • No watch service translation. JailedFileSystem.newWatchService() delegates straight to the native file system, which returns native WatchKey instances whose Watchable is a native path. Callers that need jail-scoped watch events must translate on the way out.

  • No in-jail file store abstraction. FileStore queries delegate to the native provider.

  • No jail-level quota, audit, or interception hooks. The translator answers a single question — "is this path inside the jail?" — and everything else is native behaviour.

Class / interface Role

JailedFileSystemProvider

Entry point. Manages a single JailedFileSystem instance and a JailedPathTranslator. Every operation translates its path arguments with the translator and forwards to the native provider.

JailedFileSystem

FileSystem implementation. Delegates lifecycle, attribute views, PathMatcher, and WatchService queries to the translator’s native file system. Its getPath(String, String…​) returns a JailedPath.

JailedPath

Custom Path implementation. Stores the raw text as char[] and parses Segment boundaries once at construction to make navigation allocation-free. Compatible only with other JailedPath instances — cross-path operations with a foreign Path throw ProviderMismatchException. register(WatchService, …​) is unsupported.

JailedPathTranslator

Strategy interface: getNativeFileSystem, wrapPath, unwrapPath.

AbstractJailedPathTranslator

Base class providing reusable wrapPath(rootPath, …​) and unwrapPath(rootPath, …​) helpers that perform the segment-level translation. Subclasses supply the root.

RootedPathTranslator

Fixed-root translator. The native root is passed at construction and never changes.

ContextSensitiveRootedPathTranslator

Per-call-root translator. Reads a RootedFileSystemContext from ContextFactory.getContext(…​) on every wrapPath / unwrapPath call.

RootedFileSystemContext

JAXB-annotated Context carrying the native root string. Installed into the ambient ContextFactory stack before operations run.

JailedURIUtility

Static URI validation and conversion helpers, mirroring EphemeralURIUtility.

The translator interface is the policy boundary for the jail. The module ships two implementations:

A single native root decided at construction time. Appropriate when the jail boundary is known when the translator is created and never needs to change:

import java.nio.file.FileSystems;
import java.nio.file.Path;
import org.smallmind.file.jailed.JailedFileSystemProvider;
import org.smallmind.file.jailed.RootedPathTranslator;

Path nativeRoot = FileSystems.getDefault().getPath("/var/lib/tenant-42");

JailedFileSystemProvider provider =
  new JailedFileSystemProvider("jailed", new RootedPathTranslator(nativeRoot));

A JailedPath.resolve("/etc/passwd") through this provider will translate to /var/lib/tenant-42/etc/passwd on the native side — which either resolves to a file that tenant has deliberately placed there or to NoSuchFileException. What it never resolves to is the real /etc/passwd, because RootedPathTranslator.unwrapPath(jailedPath) appends segments to nativeRoot rather than treating them as absolute.

The native root is read on every call from a thread-bound RootedFileSystemContext. Appropriate when the same provider must serve different tenants, sessions, or request scopes in the same JVM:

import java.nio.file.FileSystems;
import org.smallmind.file.jailed.ContextSensitiveRootedPathTranslator;
import org.smallmind.file.jailed.JailedFileSystemProvider;
import org.smallmind.file.jailed.RootedFileSystemContext;
import org.smallmind.nutsnbolts.context.ContextFactory;

JailedFileSystemProvider provider =
  new JailedFileSystemProvider(
    "jailed",
    new ContextSensitiveRootedPathTranslator(FileSystems.getDefault()));

// later, at request time:
RootedFileSystemContext ctx = new RootedFileSystemContext();
ctx.setRoot("/var/lib/tenant-42");
ContextFactory.pushContext(ctx);                          (1)

try {
  // all jailed-path operations on this thread now translate against
  // /var/lib/tenant-42
} finally {
  ContextFactory.popContext(RootedFileSystemContext.class);  (2)
}
  1. The push/pop API here is the smallmind-nutsnbolts ContextFactory contract; consult that module for the exact method names and thread-binding semantics.

  2. If the context is missing or its getRoot() is null when a jailed-file-system operation runs, ContextSensitiveRootedPathTranslator throws SecurityException with the message "No authorization for path". The jail fails closed.

The context-sensitive translator is the recommended pattern for services that handle multiple tenants, users, or request scopes through one JailedFileSystemProvider. The typical shape is:

  1. A request-scoped interceptor (servlet filter, Jersey filter, RPC request handler, …) resolves the authorised native root for the current caller.

  2. The interceptor pushes a RootedFileSystemContext with that root onto the ContextFactory stack.

  3. Business logic performs Files.* operations on JailedPath instances. Each call hits unwrapPath(…​), which consults the context, and produces an absolute native path anchored at the request’s root.

  4. The interceptor pops the context on request completion. Any subsequent operation without a context fails closed with SecurityException.

Because the context is consulted per call, the caller never has to manage a file-system instance per tenant; the single provider serves all of them.

The no-arg JailedFileSystemProvider() is what the SPI instantiates when the artifact is on the classpath. It calls:

this("jailed", new ContextSensitiveRootedPathTranslator(FileSystems.getDefault()));

That means just dropping file-jailed on the classpath registers a jailed: provider whose root must be supplied at call time via a RootedFileSystemContext. Without a context in scope, every call fails with SecurityException.

A service that wants different defaults — a fixed root, a different scheme, a non-default native file system — must construct and register its own JailedFileSystemProvider explicitly. The SPI-installed default is intentionally useless without a context: fail-closed is the safer initial state.

Using RootedPathTranslator with a fixed root:

import java.net.URI;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import org.smallmind.file.jailed.JailedFileSystemProvider;
import org.smallmind.file.jailed.RootedPathTranslator;

public class JailedDemo {

  public static void main (String[] args)
    throws Exception {

    Path root = FileSystems.getDefault().getPath("/var/lib/tenant-42");        (1)
    Files.createDirectories(root);

    JailedFileSystemProvider provider =
      new JailedFileSystemProvider("jailed", new RootedPathTranslator(root));  (2)
    FileSystem fs = provider.getFileSystem(URI.create("jailed:///"));          (3)

    Path inside = fs.getPath("/reports/2026/q2.csv");                          (4)
    Files.createDirectories(inside.getParent());
    Files.writeString(inside, "col1,col2\n1,2\n");

    Path escapeAttempt = fs.getPath("/../../etc/passwd");                      (5)
    try {
      Files.readAllBytes(escapeAttempt);
    } catch (SecurityException ignored) {
      // expected: translator rejects the attempt on wrap/unwrap
    }
  }
}
  1. The native root. Whatever this directory contains is what the jail exposes under /.

  2. Construct the provider with a fixed-root translator.

  3. The URI here must be "jailed:///" — scheme matches, authority is absent, path is exactly "/", no query, no fragment (see JailedURIUtility.checkUri).

  4. Creating a directory and writing a file inside the jail — the Files.* calls see a straightforward absolute path; the translator converts it to /var/lib/tenant-42/reports/2026/q2.csv on the way into the native provider.

  5. Attempting to escape. .. segments are not expanded by the translator itself; normalisation happens on the native side, and if the native path falls outside the root, wrapPath’s `startsWith check rejects it when the result of a listing iterator needs to be re-wrapped. In practice the escape surface is the set of places where wrapPath runs — primarily directory listings and any caller that passes a native path back through.

Note

The translator’s unwrapPath is effectively a resolve operation: it treats the jailed path as relative to the native root and appends its segments. A ".." segment in the jailed path will travel into the native path as a literal .., and the native provider will evaluate it. If the native provider normalises the resulting path out of the jail — for example, because /var/lib/tenant-42/../../etc/passwd resolves to /etc/passwd — you lose containment. Either pre-normalise on the caller side (path.normalize() on the jailed path rejects .. escapes by wrapping), or require symlink-free roots whose parent chains are trusted.

import java.net.URI;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import org.smallmind.file.ephemeral.EphemeralFileSystem;
import org.smallmind.file.ephemeral.EphemeralFileSystemProvider;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeClass;

public class FooTest {

  private EphemeralFileSystemProvider provider;
  private FileSystem fs;

  @BeforeClass
  public void setup () {

    provider = new EphemeralFileSystemProvider("test");
    fs = provider.getFileSystem(URI.create("test:///"));
  }

  @AfterMethod
  public void resetHeap () {

    ((EphemeralFileSystem)fs).clear();
  }

  // tests use fs.getPath(...) freely; each test starts with an empty heap.
}

Mount an in-memory scratch area at /opt/scratch while leaving everything else on real disk. Start the JVM with:

-Djava.nio.file.spi.DefaultFileSystemProvider=org.smallmind.file.ephemeral.EphemeralFileSystemProvider
-Dorg.smallmind.file.ephemeral.configuration.roots=/opt/scratch
-Dorg.smallmind.file.ephemeral.configuration.capacity=2147483648

Paths.get("/opt/scratch/session-123/state.bin") is now an ephemeral path, served from heap, with all NIO operations including watch available. Paths.get("/etc/hosts") still reads the real file.

Request handler pseudocode, using the context-sensitive translator installed by the SPI default:

import java.net.URI;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import org.smallmind.file.jailed.RootedFileSystemContext;
import org.smallmind.nutsnbolts.context.ContextFactory;

public void handleUpload (String tenantId, String relativePath, byte[] bytes)
  throws IOException {

  RootedFileSystemContext ctx = new RootedFileSystemContext();
  ctx.setRoot("/var/lib/tenants/" + tenantId);
  ContextFactory.pushContext(ctx);

  try {
    FileSystem fs = FileSystems.getFileSystem(URI.create("jailed:///"));
    Path target = fs.getPath("/").resolve(relativePath).normalize();   (1)

    Files.createDirectories(target.getParent());
    Files.write(target, bytes);
  } finally {
    ContextFactory.popContext(RootedFileSystemContext.class);
  }
}
  1. Normalise inside the jail so that .. sequences collapse within the JailedPath before the translator sees them. Combined with a symlink-free tenant root, this is the minimum precaution against path-traversal escapes.

EphemeralFileSystem.getPath(first, more) decides between EphemeralPath and NativePath by calling EphemeralFileSystemConfiguration.isOurs(first, more), which performs a character-level prefix match. The match walks the concatenated path character-by-character without first building a full path string; a root matches when all of its characters are consumed in order by the leading portion of the assembled path.

Implication: configuration entries like "/opt/scratch" and "/opt" behave differently. "/opt/scratchy/foo" matches "/opt" but not "/opt/scratch". Plan the root set to avoid accidental capture of sibling trees.

EphemeralFileSystem.close() is a no-op when the file system is installed as the default provider (isDefault() == true). Otherwise it sets the closed flag. The class deliberately does not close open channels, directory streams, or the watch service; a TODO in the source acknowledges the gap. Callers who need a hard shutdown for a non-default instance should close outstanding resources themselves before calling close().

  • Registrations install a per-watched-path heap listener on first register per path and remove it on last cancel.

  • Heap mutations performed through the file store and the seekable byte channel (createDirectory, delete, copy, move, newByteChannel with CREATE / CREATE_NEW / TRUNCATE_EXISTING, channel write / truncate) bubble events up to ancestor watchers, including watchers registered on directories above the changed entry. The WatchEvent.context() is the path relative to the watched directory.

  • Polling: poll(timeout, unit) with a timeout shorter than 500 ms delegates to the underlying LinkedBlockingQueue.poll. With a longer timeout, the service polls in 500 ms increments, checking closed between polls so that close() mid-wait raises ClosedWatchServiceException promptly.

  • take() behaves identically but has no upper bound.

  • A WatchKey that is fired while already signalled does not re-queue; subsequent fires are still added to the key’s event queue. Consumers who neglect to call reset() after pollEvents() silently stop receiving signals for that key.

EphemeralFileStore.copy(source, target, options) and move(source, target, options) both:

  • accept only StandardCopyOption values; anything else throws UnsupportedOperationException;

  • honour only StandardCopyOption.REPLACE_EXISTING (others are accepted but have no effect);

  • treat source.equals(target) as a no-op;

  • reject attempts to overwrite the root directory (throw IOException with message "Not Allowed").

The difference: copy allocates a fresh ByteArrayIOBuffer per file copy (content duplication), while move transfers the source’s ByteArrayIOBuffer by reference and then unlinks the source node (zero-copy content transfer).

Every cross-path operation on a JailedPath (startsWith, endsWith, resolve, relativize, compareTo) requires the other argument to be a JailedPath. A foreign Path raises ProviderMismatchException with no message. This is a stricter contract than the JDK’s default Path types; be careful when mixing JailedPath with paths returned from other file systems.

JailedFileSystemProvider.getFileStore(Path), readAttributes(…​), getFileAttributeView(…​), and setAttribute(…​) all translate the path and forward to the native provider. The returned FileStore / FileAttributeView / BasicFileAttributes instances are the native types, not jail-specific wrappers. Callers who need jail-scoped attributes (for example, a file size that is relative to an accounting quota, not the native store’s total capacity) must wrap the returned values themselves.

ProviderMismatchException

Thrown whenever an operation receives a Path whose file system is not an EphemeralFileSystem. The message identifies the offending path and the expected class. Two-argument operations (copy, move, isSameFile) also raise this exception when the two paths belong to different EphemeralFileSystem instances, with the message "The source and target are associated with different file systems".

ClosedFileSystemException

Thrown by EphemeralFileStore, EphemeralFileSystem, and EphemeralWatchService whenever a post-close method is called.

FileSystemAlreadyExistsException

Always thrown by EphemeralFileSystemProvider.newFileSystem(URI, Map). The provider pre-constructs the singleton in its constructor; callers must use getFileSystem(URI) instead.

NoSuchFileException / FileAlreadyExistsException / DirectoryNotEmptyException:: Standard NIO semantics, produced by EphemeralFileStore for the matching conditions (missing parent, existing non-REPLACE_EXISTING target, non-empty directory on delete or directory-target copy).

UnsupportedOperationException

Raised when a caller passes a non-StandardOpenOption to newByteChannel, a non-StandardCopyOption to copy / move, or a FileAttribute whose name is not "posix:permissions" to createDirectory / newByteChannel. Also raised by newFileChannel / newAsynchronousFileChannel when the path is not a NativePath.

SecurityException

Raised by AbstractJailedPathTranslator.wrapPath when a native path does not start with the configured root, and by ContextSensitiveRootedPathTranslator.wrapPath / unwrapPath when no RootedFileSystemContext is present or its root is null. The message is always "No authorization for path".

ProviderMismatchException

Raised by any JailedPath cross-path operation that receives a non-JailedPath argument.

FileSystemAlreadyExistsException

Always thrown by JailedFileSystemProvider.newFileSystem(URI, Map).

IllegalArgumentException

Raised by JailedURIUtility for URIs that are not well-formed for the provider (wrong scheme, non-empty authority, path other than "/", any query or fragment). Also raised by JailedPath.relativize when the two paths differ in absoluteness.

Setting Default Where set

Ephemeral capacity

Long.MAX_VALUE

EphemeralFileSystemConfiguration.deriveCapacity()

Ephemeral block size

1024

EphemeralFileSystemConfiguration.deriveBlockSize()

Ephemeral roots

{"/"}

EphemeralFileSystemConfiguration.deriveRoots()

Ephemeral provider scheme (no-arg)

"ephemeral"

EphemeralFileSystemProvider()

Ephemeral watch poll interval

500 ms

EphemeralWatchService.poll(long, TimeUnit) / take()

Jailed provider scheme (no-arg)

"jailed"

JailedFileSystemProvider()

Jailed default translator (no-arg)

ContextSensitiveRootedPathTranslator(FileSystems.getDefault())

JailedFileSystemProvider()

Jailed failure mode with no context

SecurityException("No authorization for path")

ContextSensitiveRootedPathTranslator.wrapPath / unwrapPath

Jailed path separator

'/'

JailedPath.SEPARATOR

ProviderMismatchException: The path(X) is not associated with the EphemeralFileSystem

A Path that did not come from an EphemeralFileSystem was handed to the ephemeral provider. Common cause: mixing java.nio.file.Paths.get(…​) output when the ephemeral provider is not installed as the default with a non-default ephemeral provider instance. Resolve by obtaining paths from the specific file system you intend to use (ephemeralFs.getPath(…​)).

NullPointerException deep inside EphemeralFileSystemProvider on a non-matching path

A provider constructed with the no-arg or String-scheme form has no native file system captured. Paths outside the configured roots hit provider.getNativeFileSystem() which is null. Use the EphemeralFileSystemProvider(FileSystemProvider) constructor, or ensure every path matches a configured root.

EphemeralFileSystem operations hang at startup under the default-provider install

Some thread is calling FileSystems.getDefault() (or Paths.get(…​)) before the provider constructor has completed. Call EphemeralFileSystemProvider.waitForInitialization(timeout, unit) from your startup path, or sequence your initialization so the provider constructs before concurrent callers run.

SecurityException("No authorization for path") in an apparently simple jailed call

The current thread has no RootedFileSystemContext in the context stack, or the context’s root is null. Push a context before the operation, and guarantee the push happens even when an exception is thrown by the path-setup code — the whole point of SecurityException here is that the jail fails closed.

JailedPath operations return a path that "escapes" the jail

Check whether the root directory contains symlinks. The translator only validates startsWith(rootPath); the underlying native provider is free to follow symlinks out of the root. Either remove the symlinks or put a filesystem-level protection around the root.

IllegalArgumentException("Path component should be '/'") when obtaining a JailedFileSystem

getFileSystem(URI) was called with a URI other than "jailed:///". The path component of the URI must be exactly "/". Paths within the jail are built with fs.getPath(…​), not URIs.

No symbolic links in the ephemeral heap

EphemeralBasicFileAttributes.isSymbolicLink always returns false; toRealPath is normalize().toAbsolutePath(). Code paths that depend on symlink semantics will behave differently on ephemeral paths than on native ones.

Ephemeral close() does not close open resources

A close() call on an EphemeralFileSystem that is not the default provider flips the closed flag but leaves open channels and directory streams alone. The file store’s open-resource tracking is a known TODO in the source.

No FileChannel / AsynchronousFileChannel for ephemeral paths

EphemeralFileSystemProvider.newFileChannel and newAsynchronousFileChannel throw UnsupportedOperationException unless the path is a NativePath. Code that needs memory-mapped access to ephemeral content must work through EphemeralSeekableByteChannel or read the full buffer into heap.

Ephemeral attribute model is deliberately shallow

Only the basic view is supported. posix, dos, owner, and acl views are not implemented. setAttribute recognises only creationTime, lastModifiedTime, and lastAccessTime; other names are silently ignored.

Jailed register(WatchService, …​) is unimplemented

JailedPath.register(…​) throws UnsupportedOperationException. Watching inside a jail requires using the native file system’s watch service and translating paths manually.

Jailed translators do not defend against native symlink traversal

The containment guarantee is startsWith(rootPath) at the path level. Whether the native provider respects that containment when it dereferences symlinks is out of scope; callers must curate the root directory accordingly.

Single-instance per provider

Both providers hold exactly one FileSystem instance. Multiple jails or multiple ephemeral overlays in the same JVM require multiple provider instances, each constructed by the application (not through the default SPI registration).

smallmind-file supplies two small, complete java.nio.file file system implementations that fill two concrete gaps in the JDK:

  • file-ephemeral gives you an in-memory FileSystem with native pass-through and watch support, installable as the JVM default for transparent overlay behaviour or usable side-by-side with the platform provider for tests and scratch areas.

  • file-jailed gives you a chroot-style FileSystem on top of an existing native one, with a pluggable translator strategy that supports both fixed-root and per-request root selection.

Both modules integrate through the standard Paths, Files, and FileSystems APIs and depend on nothing outside smallmind-nutsnbolts. Pick whichever one matches the need: heap-speed storage that behaves like a real file system, or a hard boundary around a subtree of a real file system.