This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/master by this push:
new 60e4b5a8e8 fix(commons,marshall): LocalDir path-traversal defense +
ClassFormatSwap session classloader (TODO-125, TODO-138)
60e4b5a8e8 is described below
commit 60e4b5a8e8134fa90acdf73a7f1acb03edac4627
Author: James Bognar <[email protected]>
AuthorDate: Thu May 28 15:25:23 2026 -0400
fix(commons,marshall): LocalDir path-traversal defense + ClassFormatSwap
session classloader (TODO-125, TODO-138)
TODO-125: Route LocalDir.resolve() through FileUtils.resolveSafely() on
the filesystem-root branch — path-traversal protection now enforced at
the helper level. Classpath branch gains a ".." guard for parity.
17 new tests. Release-notes security entry.
TODO-138: Fix ClassFormatSwap.unswap() and MarshalledPropertyPostProcessor
to consult the session classloader before falling back to
Thread.currentThread().getContextClassLoader(). Sessions that explicitly
set a classloader (OSGi bundles, plugin systems, webapps) now correctly
resolve Class<?> values from that classloader.
New API: MarshallingContext.Builder.classLoader(ClassLoader) with fluent
overrides in Serializer.Builder, Parser.Builder, and
MarshallingContextable.Builder. 3 new isolation tests. Release-notes
bug entry.
---
.../org/apache/juneau/commons/io/LocalDir.java | 35 +++-
.../juneau/MarshalledPropertyPostProcessor.java | 5 +-
.../java/org/apache/juneau/MarshallingContext.java | 45 ++++-
.../org/apache/juneau/MarshallingContextable.java | 11 ++
.../java/org/apache/juneau/MarshallingSession.java | 11 ++
.../main/java/org/apache/juneau/parser/Parser.java | 6 +
.../org/apache/juneau/serializer/Serializer.java | 6 +
.../org/apache/juneau/swaps/ClassFormatSwap.java | 5 +-
.../commons/io/LocalDir_PathTraversal_Test.java | 193 +++++++++++++++++++++
.../ClassFormatSwap_SessionClassLoader_Test.java | 174 +++++++++++++++++++
juneau-utest/test-run-history.tsv | 1 +
11 files changed, 480 insertions(+), 12 deletions(-)
diff --git
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/io/LocalDir.java
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/io/LocalDir.java
index 82bb8eaeb7..b624b0971b 100644
---
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/io/LocalDir.java
+++
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/io/LocalDir.java
@@ -18,11 +18,14 @@ package org.apache.juneau.commons.io;
import static org.apache.juneau.commons.utils.AssertionUtils.*;
import static org.apache.juneau.commons.utils.StringUtils.*;
+import static org.apache.juneau.commons.utils.ThrowableUtils.*;
import static org.apache.juneau.commons.utils.Utils.*;
import java.net.*;
import java.nio.file.*;
+import org.apache.juneau.commons.utils.*;
+
/**
* Represents a directory that can be located either on the classpath or in
the file system.
*
@@ -220,19 +223,34 @@ public class LocalDir {
* LocalFile <jv>file2</jv> =
<jv>dir</jv>.resolve(<js>"pages/about.html"</js>);
* </p>
*
- * <h5 class='section'>Security Note:</h5>
+ * <h5 class='section'>Security (CWE-22 / path-traversal):</h5>
+ * <p>
+ * For filesystem-root {@code LocalDir} instances, path resolution is
delegated to
+ * {@link FileUtils#resolveSafely(java.io.File, String)} which enforces
a strict boundary check:
+ * any resolved path that escapes the configured root (via {@code ../},
absolute-path injection,
+ * or symlinks pointing outside the root) is rejected with {@link
IllegalArgumentException}.
+ * This makes {@code LocalDir} the single-source-of-truth enforcement
point — callers do not
+ * need to pre-validate the path.
+ * </p>
+ *
* <p>
- * This method does not perform path validation or security checks
(e.g., checking for path
- * traversal attacks or malformed values). The caller is responsible
for ensuring the path
- * is safe and valid.
+ * For classpath-resource {@code LocalDir} instances, path traversal
via {@code ..} segments is
+ * rejected with {@link IllegalArgumentException}. URL-encoded
traversal sequences (e.g.
+ * {@code %2e%2e}) are <em>not</em> decoded by this method and
therefore treated as literal path
+ * segments — callers that receive URL-encoded input must decode before
calling.
+ * </p>
*
* @param path The relative path to the file to resolve within this
directory.
* Must be a non-null relative path.
* @return A {@link LocalFile} instance if the file exists and is
readable, or <jk>null</jk> if it does not.
+ * @throws IllegalArgumentException If the resolved path escapes the
configured root (filesystem branch),
+ * or if the path contains {@code ..}
segments (classpath branch).
*/
public LocalFile resolve(String path) {
assertArgNotNull(ARG_path, path);
if (nn(clazz)) {
+ if (path.contains(".."))
+ throw illegalArg("Path escapes configured root
directory.");
String p;
if (clazzPath == null) {
// Relative to class package - keep path
relative
@@ -250,9 +268,12 @@ public class LocalDir {
if (isClasspathFile(clazz.getResource(p)))
return new LocalFile(clazz, p);
} else {
- var p = this.path.resolve(path);
- if (Files.isReadable(p) && ! Files.isDirectory(p))
- return new LocalFile(p);
+ var opt = FileUtils.resolveSafely(this.path.toFile(),
path);
+ if (opt.isPresent()) {
+ var p = opt.get().toPath();
+ if (Files.isReadable(p) && !
Files.isDirectory(p))
+ return new LocalFile(p);
+ }
}
return null;
}
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/MarshalledPropertyPostProcessor.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/MarshalledPropertyPostProcessor.java
index a335d8a186..fbd5c64fe6 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/MarshalledPropertyPostProcessor.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/MarshalledPropertyPostProcessor.java
@@ -1034,7 +1034,10 @@ final class MarshalledPropertyPostProcessor implements
BeanPropertyPostProcessor
@Override /* ObjectSwap */
public Class<?> unswap(MarshallingSession session,
String o, ClassMeta<?> hint) {
- return ClassFormat.parse(o, format,
Thread.currentThread().getContextClassLoader());
+ ClassLoader cl = session != null ?
session.getClassLoader() : null;
+ if (cl == null)
+ cl =
Thread.currentThread().getContextClassLoader();
+ return ClassFormat.parse(o, format, cl);
}
};
}
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/MarshallingContext.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/MarshallingContext.java
index eff63000ea..071cf551c7 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/MarshallingContext.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/MarshallingContext.java
@@ -220,6 +220,7 @@ public class MarshallingContext extends Context implements
ConversionFinder, Bea
private static final String PROP_floatFormat = "floatFormat";
private static final String PROP_currencyFormat = "currencyFormat";
private static final String PROP_classFormat = "classFormat";
+ private static final String PROP_classLoader = "classLoader";
private static final String PROP_useInterfaceProxies =
"useInterfaceProxies";
private static final String PROP_useJavaBeanIntrospector =
"useJavaBeanIntrospector";
private static final String PROP_validateSchema = "validateSchema";
@@ -296,6 +297,7 @@ public class MarshallingContext extends Context implements
ConversionFinder, Bea
private FloatFormat floatFormat;
private CurrencyFormat currencyFormat;
private ClassFormat classFormat;
+ private ClassLoader classLoader;
private Class<? extends PropertyNamer> propertyNamer;
private List<ClassInfo> beanDictionary;
private List<Object> swaps;
@@ -406,6 +408,7 @@ public class MarshallingContext extends Context implements
ConversionFinder, Bea
floatFormat = copyFrom.floatFormat;
currencyFormat = copyFrom.currencyFormat;
classFormat = copyFrom.classFormat;
+ classLoader = copyFrom.classLoader;
typePropertyName = copyFrom.typePropertyName;
useJavaBeanIntrospector =
copyFrom.useJavaBeanIntrospector;
validateSchema = copyFrom.validateSchema;
@@ -461,6 +464,7 @@ public class MarshallingContext extends Context implements
ConversionFinder, Bea
floatFormat = copyFrom.floatFormat;
currencyFormat = copyFrom.currencyFormat;
classFormat = copyFrom.classFormat;
+ classLoader = copyFrom.classLoader;
typePropertyName = copyFrom.typePropertyName;
useJavaBeanIntrospector =
copyFrom.useJavaBeanIntrospector;
validateSchema = copyFrom.validateSchema;
@@ -2394,7 +2398,8 @@ public class MarshallingContext extends Context
implements ConversionFinder, Bea
classFormat,
locale,
propertyNamer,
- System.identityHashCode(beanStore)
+ System.identityHashCode(beanStore),
+ System.identityHashCode(classLoader)
);
// @formatter:on
}
@@ -3683,6 +3688,26 @@ public class MarshallingContext extends Context
implements ConversionFinder, Bea
return this;
}
+ /**
+ * Session classloader.
+ *
+ * <p>
+ * Sets the classloader used when parsing wire-format class
names (e.g. FQCN or binary-name strings
+ * written by {@link ClassFormatSwap}). When <jk>null</jk>,
the thread-context classloader is
+ * used as a fallback — which is the historical behavior.
+ *
+ * <p>
+ * Useful in OSGi bundles, webapp classloaders, and plugin
systems where the thread-context
+ * classloader does not have visibility into the classes that
the session needs to resolve.
+ *
+ * @param value The classloader to use for class resolution.
Can be <jk>null</jk> to reset to thread-context fallback.
+ * @return This object.
+ */
+ public Builder classLoader(ClassLoader value) {
+ classLoader = value;
+ return this;
+ }
+
@Override /* Overridden from Builder */
public Builder type(Class<? extends Context> value) {
assertArgNotNull(ARG_value, value);
@@ -3996,6 +4021,7 @@ public class MarshallingContext extends Context
implements ConversionFinder, Bea
private final FloatFormat floatFormat;
private final CurrencyFormat currencyFormat;
private final ClassFormat classFormat;
+ private final ClassLoader classLoader;
private final Visibility beanClassVisibility;
private final Visibility beanConstructorVisibility;
private final Visibility beanFieldVisibility;
@@ -4051,6 +4077,7 @@ public class MarshallingContext extends Context
implements ConversionFinder, Bea
floatFormat = builder.floatFormat;
currencyFormat = builder.currencyFormat;
classFormat = builder.classFormat;
+ classLoader = builder.classLoader;
typePropertyName =
opt(builder.typePropertyName).orElse("_type");
useInterfaceProxies = ! builder.disableInterfaceProxies;
useJavaBeanIntrospector = builder.useJavaBeanIntrospector;
@@ -5257,6 +5284,17 @@ public class MarshallingContext extends Context
implements ConversionFinder, Bea
*/
public final ClassFormat getClassFormat() { return classFormat; }
+ /**
+ * Session classloader.
+ *
+ * <p>
+ * Returns the classloader explicitly configured via {@link
Builder#classLoader(ClassLoader)}, or
+ * <jk>null</jk> if none was set (callers should fall back to the
thread-context classloader).
+ *
+ * @return The session classloader, or <jk>null</jk> if not set.
+ */
+ public final ClassLoader getClassLoader() { return classLoader; }
+
/**
* Ignore transient fields.
*
@@ -5363,8 +5401,9 @@ public class MarshallingContext extends Context
implements ConversionFinder, Bea
.a(PROP_booleanFormat, booleanFormat)
.a(PROP_floatFormat, floatFormat)
.a(PROP_currencyFormat, currencyFormat)
- .a(PROP_classFormat, classFormat)
- .a(PROP_useInterfaceProxies, useInterfaceProxies)
+ .a(PROP_classFormat, classFormat)
+ .a(PROP_classLoader, classLoader)
+ .a(PROP_useInterfaceProxies, useInterfaceProxies)
.a(PROP_useJavaBeanIntrospector,
useJavaBeanIntrospector)
.a(PROP_validateSchema, validateSchema);
}
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/MarshallingContextable.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/MarshallingContextable.java
index a7fac4e9e2..79fc19b409 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/MarshallingContextable.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/MarshallingContextable.java
@@ -2994,6 +2994,17 @@ public abstract class MarshallingContextable extends
Context {
return this;
}
+ /**
+ * Session classloader.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder classLoader(ClassLoader value) {
+ bcBuilder.classLoader(value);
+ return this;
+ }
+
@Override /* Overridden from Builder */
public Builder type(Class<? extends Context> value) {
super.type(value);
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/MarshallingSession.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/MarshallingSession.java
index e85bfbf288..fefdb67741 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/MarshallingSession.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/MarshallingSession.java
@@ -740,6 +740,17 @@ public class MarshallingSession extends ContextSession
implements ConverterSessi
*/
public final PropertyNamer getPropertyNamer() { return
ctx.getPropertyNamer(); }
+ /**
+ * Session classloader.
+ *
+ * <p>
+ * Returns the classloader explicitly configured via {@link
MarshallingContext.Builder#classLoader(ClassLoader)},
+ * or <jk>null</jk> if none was set (callers should fall back to the
thread-context classloader).
+ *
+ * @return The session classloader, or <jk>null</jk> if not set.
+ */
+ public final ClassLoader getClassLoader() { return
ctx.getClassLoader(); }
+
/**
* Java object swaps.
*
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parser/Parser.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parser/Parser.java
index e242f25309..5fb8cb579d 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parser/Parser.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parser/Parser.java
@@ -294,6 +294,12 @@ public class Parser extends MarshallingContextable {
return this;
}
+ @Override /* Overridden from Builder */
+ public Builder classLoader(ClassLoader value) {
+ super.classLoader(value);
+ return this;
+ }
+
@Override /* Overridden from Builder */
public Builder annotations(Annotation...values) {
super.annotations(values);
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/serializer/Serializer.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/serializer/Serializer.java
index 6fd576126b..9b70fe242e 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/serializer/Serializer.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/serializer/Serializer.java
@@ -275,6 +275,12 @@ public class Serializer extends MarshallingTraverseContext
{
return this;
}
+ @Override /* Overridden from Builder */
+ public Builder classLoader(ClassLoader value) {
+ super.classLoader(value);
+ return this;
+ }
+
/**
* Specifies the accept media types that the serializer
can handle.
*
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/swaps/ClassFormatSwap.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/swaps/ClassFormatSwap.java
index daa3040b63..8fbc70a30c 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/swaps/ClassFormatSwap.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/swaps/ClassFormatSwap.java
@@ -54,7 +54,10 @@ public class ClassFormatSwap extends StringSwap<Class<?>> {
if (o == null)
return null;
var fmt = resolveFormat(session);
- return ClassFormat.parse(o, fmt,
Thread.currentThread().getContextClassLoader());
+ ClassLoader cl = session != null ? session.getClassLoader() :
null;
+ if (cl == null)
+ cl = Thread.currentThread().getContextClassLoader();
+ return ClassFormat.parse(o, fmt, cl);
}
private static ClassFormat resolveFormat(MarshallingSession session) {
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/commons/io/LocalDir_PathTraversal_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/commons/io/LocalDir_PathTraversal_Test.java
new file mode 100644
index 0000000000..6b11973c53
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/commons/io/LocalDir_PathTraversal_Test.java
@@ -0,0 +1,193 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.commons.io;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.io.*;
+import java.nio.file.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Path-traversal (CWE-22) boundary tests for {@link LocalDir#resolve(String)}.
+ *
+ * <p>
+ * Covers both the filesystem-root branch (delegates to {@code
FileUtils.resolveSafely}) and
+ * the classpath-resource branch ({@code ..} segment rejection).
+ */
+class LocalDir_PathTraversal_Test extends TestBase {
+
+ private static final Path TEST_DIR =
Paths.get("src/test/resources/files");
+
+
//====================================================================================================
+ // Filesystem branch — happy-path resolution
+
//====================================================================================================
+
+ @Test void a01_filesystem_happyPath() {
+ var dir = new LocalDir(TEST_DIR);
+ var file = dir.resolve("Test3.properties");
+ assertNotNull(file);
+ assertEquals("Test3.properties", file.getName());
+ }
+
+ @Test void a02_filesystem_happyPath_subdirectory() {
+ var dir = new LocalDir(TEST_DIR);
+ var file = dir.resolve("test1");
+ // "test1" is a subdirectory, not a file; resolve should return
null for directories
+ assertNull(file);
+ }
+
+ @Test void a03_filesystem_nonexistent_returnsNull() {
+ var dir = new LocalDir(TEST_DIR);
+ var file = dir.resolve("nonexistent.txt");
+ assertNull(file);
+ }
+
+
//====================================================================================================
+ // Filesystem branch — path-traversal rejection
+
//====================================================================================================
+
+ @Test void b01_filesystem_singleDotDot_throws() {
+ var dir = new LocalDir(TEST_DIR);
+ assertThrows(IllegalArgumentException.class, () ->
dir.resolve("../pom.xml"));
+ }
+
+ @Test void b02_filesystem_multiSegmentDotDot_throws() {
+ var dir = new LocalDir(TEST_DIR);
+ assertThrows(IllegalArgumentException.class, () ->
dir.resolve("../../pom.xml"));
+ }
+
+ @Test void b03_filesystem_deepEscape_throws() {
+ var dir = new LocalDir(TEST_DIR);
+ assertThrows(IllegalArgumentException.class, () ->
dir.resolve("../../../etc/passwd"));
+ }
+
+ @Test void b04_filesystem_absolutePathInjection_throws() {
+ var dir = new LocalDir(TEST_DIR);
+ // An absolute path bypasses root-relative resolution
(Path.resolve replaces the base entirely on Unix).
+ // FileUtils.resolveSafely rejects it via the startsWith(root)
boundary check.
+ assertThrows(IllegalArgumentException.class, () ->
dir.resolve("/etc/passwd"));
+ }
+
+ @Test void b05_filesystem_dotDotWithSandwich_throws() {
+ // "subdir/../../../etc/passwd" — traversal buried inside an
otherwise-plausible path
+ var dir = new LocalDir(TEST_DIR);
+ assertThrows(IllegalArgumentException.class, () ->
dir.resolve("test1/../../../etc/passwd"));
+ }
+
+ @Test void b06_filesystem_encodedTraversal_notDecoded() {
+ // "%2e%2e" is NOT decoded by LocalDir (no URL-decode step in
the call chain).
+ // It is therefore treated as a literal path segment, not as
"..".
+ // This test documents that contract: the path simply won't
match any real file.
+ var dir = new LocalDir(TEST_DIR);
+ var result = dir.resolve("%2e%2e/etc/passwd");
+ // The literal segment "%2e%2e" doesn't exist as a subdirectory
→ null, no throw
+ assertNull(result);
+ }
+
+
//====================================================================================================
+ // Filesystem branch — symlink out-of-root rejection
+
//====================================================================================================
+
+ @Test void c01_filesystem_symlinkOutOfRoot_throws() throws IOException {
+ assumeFalse(System.getProperty("os.name",
"").toLowerCase().contains("win"),
+ "Symlink test skipped on Windows");
+
+ // Create a temporary root directory with a symlink pointing
outside it
+ var tmpRoot =
Files.createTempDirectory("juneau-localdir-test-");
+ var outsideTarget = Files.createTempFile("juneau-outside-",
".txt");
+ outsideTarget.toFile().deleteOnExit();
+ var symlink = tmpRoot.resolve("escape.txt");
+ try {
+ Files.createSymbolicLink(symlink, outsideTarget);
+ var dir = new LocalDir(tmpRoot);
+ // Resolving through the symlink should be rejected
because it escapes the root
+ assertThrows(IllegalArgumentException.class, () ->
dir.resolve("escape.txt"));
+ } finally {
+ Files.deleteIfExists(symlink);
+ Files.deleteIfExists(tmpRoot);
+ }
+ }
+
+ @Test void c02_filesystem_symlinkInsideRoot_allowed() throws
IOException {
+ assumeFalse(System.getProperty("os.name",
"").toLowerCase().contains("win"),
+ "Symlink test skipped on Windows");
+
+ // A symlink that resolves INSIDE the root is allowed
+ var tmpRoot =
Files.createTempDirectory("juneau-localdir-test-");
+ var realFile = Files.createTempFile(tmpRoot, "real-", ".txt");
+ var symlink = tmpRoot.resolve("link.txt");
+ try {
+ Files.createSymbolicLink(symlink,
realFile.getFileName());
+ var dir = new LocalDir(tmpRoot);
+ // Symlink resolves inside root → allowed; file is
readable
+ assertDoesNotThrow(() -> dir.resolve("link.txt"));
+ } finally {
+ Files.deleteIfExists(symlink);
+ Files.deleteIfExists(realFile);
+ Files.deleteIfExists(tmpRoot);
+ }
+ }
+
+
//====================================================================================================
+ // Classpath branch — happy-path resolution
+
//====================================================================================================
+
+ @Test void d01_classpath_happyPath() {
+ var dir = new LocalDir(LocalDir_PathTraversal_Test.class,
"/files");
+ var file = dir.resolve("Test3.properties");
+ assertNotNull(file);
+ assertEquals("Test3.properties", file.getName());
+ }
+
+ @Test void d02_classpath_nonexistent_returnsNull() {
+ var dir = new LocalDir(LocalDir_PathTraversal_Test.class,
"/files");
+ var file = dir.resolve("nonexistent.properties");
+ assertNull(file);
+ }
+
+
//====================================================================================================
+ // Classpath branch — path-traversal rejection
+
//====================================================================================================
+
+ @Test void e01_classpath_singleDotDot_throws() {
+ var dir = new LocalDir(LocalDir_PathTraversal_Test.class,
"/files");
+ assertThrows(IllegalArgumentException.class, () ->
dir.resolve("../something"));
+ }
+
+ @Test void e02_classpath_multiSegmentDotDot_throws() {
+ var dir = new LocalDir(LocalDir_PathTraversal_Test.class,
"/files");
+ assertThrows(IllegalArgumentException.class, () ->
dir.resolve("../../something"));
+ }
+
+ @Test void e03_classpath_dotDotInMiddle_throws() {
+ var dir = new LocalDir(LocalDir_PathTraversal_Test.class,
"/files");
+ assertThrows(IllegalArgumentException.class, () ->
dir.resolve("subdir/../../../passwd"));
+ }
+
+ @Test void e04_classpath_encodedTraversal_notDecoded() {
+ // "%2e%2e" is NOT decoded; treated as a literal segment, not
as ".."
+ // This documents the contract: no URL-decode occurs in
LocalDir.
+ var dir = new LocalDir(LocalDir_PathTraversal_Test.class,
"/files");
+ var result = dir.resolve("%2e%2e/something");
+ // Literal segment won't match a classpath resource → null, no
throw
+ assertNull(result);
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/transforms/ClassFormatSwap_SessionClassLoader_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/transforms/ClassFormatSwap_SessionClassLoader_Test.java
new file mode 100644
index 0000000000..fe16cf6501
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/transforms/ClassFormatSwap_SessionClassLoader_Test.java
@@ -0,0 +1,174 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.transforms;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.net.*;
+import java.nio.file.*;
+
+import javax.tools.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.json.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Verifies that {@link org.apache.juneau.swaps.ClassFormatSwap#unswap}
consults the session-installed
+ * classloader before falling back to the thread-context classloader
(TODO-138).
+ *
+ * <p>
+ * Three scenarios are exercised:
+ * <ol>
+ * <li>Happy path — session CL resolves a class that is invisible to the
thread-context CL.</li>
+ * <li>Fallback — no session CL is set; the thread-context CL resolves the
class as before.</li>
+ * <li>Isolation — when a session CL is set, the thread-context CL is NOT
consulted as a fallback,
+ * so a class that lives only in the thread-context CL cannot be
resolved through a session CL
+ * that does not have it.</li>
+ * </ol>
+ */
+class ClassFormatSwap_SessionClassLoader_Test extends TestBase {
+
+ /**
+ * FQCN of the class compiled into the isolated temp directory. This
class does not exist on the
+ * test classpath and is therefore invisible to the thread-context
classloader.
+ */
+ private static final String ISOLATED_FQCN =
"org.apache.juneau.test.isolated.IsolatedBean";
+
+ /** URLs pointing at the isolated class output directory — passed to
the session CL. */
+ private static URL[] isolatedUrls;
+
+ /** Temp directory that holds the compiled isolated class. */
+ private static Path tempDir;
+
+ /**
+ * Compiles a tiny {@code IsolatedBean} class into a fresh temp
directory using the system Java
+ * compiler so it is invisible to the test classpath / thread-context
classloader.
+ */
+ @BeforeAll
+ static void compileIsolatedBean() throws Exception {
+ var compiler = ToolProvider.getSystemJavaCompiler();
+ assumeTrue(compiler != null, "System Java compiler unavailable
— skipping session-CL tests");
+
+ tempDir = Files.createTempDirectory("juneau-cltest-");
+ var srcPkg = tempDir.resolve(Path.of("src", "org", "apache",
"juneau", "test", "isolated"));
+ Files.createDirectories(srcPkg);
+ var srcFile = srcPkg.resolve("IsolatedBean.java");
+ Files.writeString(srcFile, "package
org.apache.juneau.test.isolated; public class IsolatedBean {}");
+
+ var outDir = tempDir.resolve("classes");
+ Files.createDirectories(outDir);
+
+ var exitCode = compiler.run(null, null, null, "-d",
outDir.toString(), srcFile.toString());
+ assertEquals(0, exitCode, "IsolatedBean compilation failed");
+
+ isolatedUrls = new URL[]{outDir.toUri().toURL()};
+ }
+
+ @AfterAll
+ static void cleanupTempDir() {
+ if (tempDir != null) {
+ try {
+ try (var walk = Files.walk(tempDir)) {
+
walk.sorted(java.util.Comparator.reverseOrder())
+ .map(Path::toFile)
+ .forEach(java.io.File::delete);
+ }
+ } catch (Exception ignored) { // HTT
+ }
+ }
+ }
+
+
//====================================================================================================
+ // a01 — happy path: session CL resolves IsolatedBean; thread-context
CL cannot
+
//====================================================================================================
+
+ /**
+ * When a session classloader is installed that contains {@code
IsolatedBean}, parsing its FQCN
+ * succeeds and the returned {@code Class} is loaded by the session CL.
+ */
+ @Test void a01_sessionCL_resolvesClassInvisibleToThreadContextCL()
throws Exception {
+ // Confirm thread-context CL cannot find IsolatedBean — our
baseline.
+ assertThrows(Exception.class,
+ () -> JsonParser.create().build().parse("\"" +
ISOLATED_FQCN + "\"", Class.class),
+ "Precondition: thread-context CL must NOT resolve
IsolatedBean"
+ );
+
+ // With the session CL pointing at the compiled class, parsing
must succeed.
+ try (var sessionCL = new URLClassLoader(isolatedUrls, null)) {
+ var parser =
JsonParser.create().classLoader(sessionCL).build();
+ var result = parser.parse("\"" + ISOLATED_FQCN + "\"",
Class.class);
+
+ assertNotNull(result);
+ assertEquals(ISOLATED_FQCN, result.getName());
+ // The class was loaded by the session CL, not the
thread-context CL.
+ assertSame(sessionCL, result.getClassLoader(),
+ "Class must be defined by the session CL, not
the thread-context CL");
+ }
+ }
+
+
//====================================================================================================
+ // a02 — fallback: no session CL → thread-context CL used (historical
behavior preserved)
+
//====================================================================================================
+
+ /**
+ * When no session classloader is configured, the thread-context
classloader is still used as the
+ * fallback — preserving the historical behavior for existing callers.
+ */
+ @Test void a02_noSessionCL_fallsBackToThreadContextCL() throws
Exception {
+ // JsonParser itself is on the test classpath and therefore
resolvable via thread-context CL.
+ var fqcn = JsonParser.class.getName();
+ var parser = JsonParser.create().build();
+
+ var result = parser.parse("\"" + fqcn + "\"", Class.class);
+
+ assertNotNull(result);
+ assertEquals(fqcn, result.getName());
+ }
+
+
//====================================================================================================
+ // a03 — isolation: session CL is set but cannot see the target → no
silent thread-CL fallback
+
//====================================================================================================
+
+ /**
+ * When a session classloader is explicitly set, the thread-context
classloader is NOT consulted
+ * as a fallback. A class that lives only on the test classpath cannot
be found through an empty
+ * session CL, even though the thread-context CL would resolve it.
+ *
+ * <p>
+ * This proves the session CL is genuinely consulted first — not the
thread CL — and that there is
+ * no silent double-lookup that would degrade to the old behavior when
the session CL comes up empty.
+ */
+ @Test void a03_sessionCL_set_noFallbackToThreadContextCL() throws
Exception {
+ // JsonParser lives on the test classpath (thread-context CL
sees it).
+ // An empty URLClassLoader with null parent only delegates to
the bootstrap CL
+ // (JDK core classes only) — it cannot see non-JDK test
classpath classes.
+ var fqcn = JsonParser.class.getName();
+
+ try (var emptyCL = new URLClassLoader(new URL[0], null)) {
+ var parser =
JsonParser.create().classLoader(emptyCL).build();
+
+ // Must fail: session CL cannot find JsonParser, and
there is no thread-CL fallback.
+ assertThrows(Exception.class,
+ () -> parser.parse("\"" + fqcn + "\"",
Class.class),
+ "Should fail — session CL is set but cannot
find " + fqcn
+ + "; thread-context CL must NOT be
consulted as a fallback"
+ );
+ }
+ }
+}
diff --git a/juneau-utest/test-run-history.tsv
b/juneau-utest/test-run-history.tsv
index ecc2e785bd..c4c4eb1cff 100644
--- a/juneau-utest/test-run-history.tsv
+++ b/juneau-utest/test-run-history.tsv
@@ -46,3 +46,4 @@ timestamp git_sha branch tests_run failures
errors skipped surefire_sec wall_sec
2026-05-27T18:16:40Z d88f65e30162 master 125727 0 0 21
77.1 101 3
2026-05-28T11:54:03Z 36d714f9b237 master 125869 0 0 21
80.9 108 3
2026-05-28T13:16:22Z 8a0a2edbf7ca master 125869 0 0 21
82.9 112 3
+2026-05-28T19:24:27Z a26b6978a5bc master 125889 0 0 21
160