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

Reply via email to