From dc102d1cd302e223c075cbcf20efc9ddd3459307 Mon Sep 17 00:00:00 2001 From: akenra <37288280+akenra@users.noreply.github.com> Date: Thu, 30 Jul 2026 05:39:01 +0500 Subject: [PATCH] fix(gcp): register nested URL protocol handler in GcfJarLauncher Handlers.register() only sets java.protocol.handler.pkgs, which the JVM resolves through the bootstrap/system classloaders. The functions-framework loads the deployed function JAR in a child URLClassLoader, so nested.Handler inside the JAR is never visible there and URLs still fail with 'unknown protocol: nested'. Install a URLStreamHandlerFactory that provides the handler directly and rework the test to load the launcher from a fat JAR through a child classloader, asserting the launcher constructs successfully. Fixes gh-1336 Signed-off-by: akenra <37288280+akenra@users.noreply.github.com> --- .../function/adapter/gcp/GcfJarLauncher.java | 41 +++- .../adapter/gcp/GcfJarLauncherTests.java | 201 ++++++++++++++++++ .../src/test/resources/ProtocolCheck.java | 54 +++++ .../test/resources/StubFunctionInvoker.java | 14 ++ 4 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/test/java/org/springframework/cloud/function/adapter/gcp/GcfJarLauncherTests.java create mode 100644 spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/test/resources/ProtocolCheck.java create mode 100644 spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/test/resources/StubFunctionInvoker.java diff --git a/spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/main/java/org/springframework/cloud/function/adapter/gcp/GcfJarLauncher.java b/spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/main/java/org/springframework/cloud/function/adapter/gcp/GcfJarLauncher.java index 53e22a6dd..d28e0e52c 100644 --- a/spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/main/java/org/springframework/cloud/function/adapter/gcp/GcfJarLauncher.java +++ b/spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/main/java/org/springframework/cloud/function/adapter/gcp/GcfJarLauncher.java @@ -16,6 +16,10 @@ package org.springframework.cloud.function.adapter.gcp; +import java.net.URL; +import java.net.URLStreamHandler; +import java.net.URLStreamHandlerFactory; + import com.google.cloud.functions.Context; import com.google.cloud.functions.HttpFunction; import com.google.cloud.functions.HttpRequest; @@ -23,6 +27,8 @@ import com.google.cloud.functions.RawBackgroundFunction; import org.springframework.boot.loader.launch.JarLauncher; +import org.springframework.boot.loader.net.protocol.Handlers; +import org.springframework.boot.loader.net.protocol.nested.Handler; /** * The launcher class written at the top-level of the output JAR to be deployed to @@ -30,15 +36,19 @@ * * @author Ray Tsang * @author Daniel Zou + * @author Roman Akentev */ public class GcfJarLauncher extends JarLauncher implements HttpFunction, RawBackgroundFunction { + private static final URLStreamHandlerFactory NESTED_URL_STREAM_HANDLER_FACTORY = new NestedUrlStreamHandlerFactory(); + private final ClassLoader loader; private final Object delegate; public GcfJarLauncher() throws Exception { - //JarFile.registerUrlProtocolHandler(); + Handlers.register(); + registerNestedUrlStreamHandlerFactory(); this.loader = createClassLoader(getClassPathUrls()); @@ -47,6 +57,25 @@ public GcfJarLauncher() throws Exception { this.delegate = clazz.getConstructor().newInstance(); } + /** + * Install a {@link URLStreamHandlerFactory} that provides the {@code nested:} + * handler directly, without relying on the {@code java.protocol.handler.pkgs} + * property. That property is only consulted by the JVM through the bootstrap + * and system classloaders, so it cannot find the handler when the loader + * classes are only visible to the classloader of the deployed fat JAR (e.g. + * when the Google Cloud Functions framework loads the JAR in a child + * {@code URLClassLoader}). + */ + private void registerNestedUrlStreamHandlerFactory() { + try { + URL.setURLStreamHandlerFactory(NESTED_URL_STREAM_HANDLER_FACTORY); + } + catch (Error error) { + // A factory is already installed. Handlers.register() above may still be + // sufficient when the loader classes are on the system classpath. + } + } + @Override public void service(HttpRequest httpRequest, HttpResponse httpResponse) throws Exception { Thread.currentThread().setContextClassLoader(this.loader); @@ -58,5 +87,15 @@ public void accept(String json, Context context) throws Exception { Thread.currentThread().setContextClassLoader(this.loader); ((RawBackgroundFunction) delegate).accept(json, context); } + + private static final class NestedUrlStreamHandlerFactory implements URLStreamHandlerFactory { + + @Override + public URLStreamHandler createURLStreamHandler(String protocol) { + return ("nested".equals(protocol)) ? new Handler() : null; + } + + } + } diff --git a/spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/test/java/org/springframework/cloud/function/adapter/gcp/GcfJarLauncherTests.java b/spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/test/java/org/springframework/cloud/function/adapter/gcp/GcfJarLauncherTests.java new file mode 100644 index 000000000..0d88a1839 --- /dev/null +++ b/spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/test/java/org/springframework/cloud/function/adapter/gcp/GcfJarLauncherTests.java @@ -0,0 +1,201 @@ +/* + * Copyright 2018-present the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.cloud.function.adapter.gcp; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Enumeration; +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import com.google.cloud.functions.HttpFunction; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.launch.Launcher; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests verifying that {@link GcfJarLauncher} registers the {@code nested:} + * URL protocol handler before creating its classloader (GH-1336). + *

+ * The test builds a Spring Boot fat JAR, then forks a subprocess that loads + * {@code GcfJarLauncher} from that JAR through a child {@code URLClassLoader} + * - mirroring how the Google Cloud Functions framework loads the deployed + * function JAR. In that setup spring-boot-loader is only visible inside the + * fat JAR, so the launcher must install a {@code nested:} handler that the + * JVM can resolve without the loader classes being on the system classpath. + * The subprocess is also checked to fail on the {@code nested:} protocol + * before the launcher is constructed, proving the handler is not already + * registered by anything else. + * + * @author Roman Akentev + * @see GH-1336 + */ +public class GcfJarLauncherTests { + + public static final String PROTOCOL_ALREADY_REGISTERED = "PROTOCOL_ALREADY_REGISTERED"; + + public static final String PROTOCOL_NOT_REGISTERED = "PROTOCOL_NOT_REGISTERED"; + + public static final String GCF_JAR_LAUNCHER_SUCCEEDED = "GCF_JAR_LAUNCHER_SUCCEEDED"; + + public static final String GCF_JAR_LAUNCHER_FAILED = "GCF_JAR_LAUNCHER_FAILED"; + + public static final String PROTOCOL_NOW_REGISTERED = "PROTOCOL_NOW_REGISTERED"; + + public static final String PROTOCOL_STILL_NOT_REGISTERED = "PROTOCOL_STILL_NOT_REGISTERED"; + + private static final String GCF_LAUNCHER_CLASS_NAME = "org.springframework.cloud.function.adapter.gcp.GcfJarLauncher"; + + private static final String FUNCTION_INVOKER_CLASS_NAME = "org.springframework.cloud.function.adapter.gcp.FunctionInvoker"; + + @TempDir + Path tempDir; + + @Test + public void nestedProtocolIsRegisteredByGcfJarLauncher() throws Exception { + String javaHome = System.getProperty("java.home"); + Path classesDir = tempDir.resolve("classes"); + compile(javaHome, classesDir); + + Path functionJar = createFunctionJar(classesDir); + Path frameworkApiJar = codeSource(HttpFunction.class); + + Process process = new ProcessBuilder(Path.of(javaHome, "bin", "java").toString(), "-cp", + String.join(File.pathSeparator, functionJar.toString(), frameworkApiJar.toString(), + classesDir.toString()), + "ProtocolCheck", functionJar.toString()) + .redirectErrorStream(true) + .start(); + boolean finished = process.waitFor(2, TimeUnit.MINUTES); + String output = new BufferedReader(new InputStreamReader(process.getInputStream())).lines() + .collect(Collectors.joining("\n")); + if (!finished) { + process.destroyForcibly(); + } + + assertThat(finished).as("ProtocolCheck timed out:\n" + output).isTrue(); + assertThat(process.exitValue()).as("ProtocolCheck exit code:\n" + output).isZero(); + assertThat(output).as("ProtocolCheck output") + .contains(PROTOCOL_NOT_REGISTERED + ": unknown protocol: nested") + .contains(GCF_JAR_LAUNCHER_SUCCEEDED) + .contains(PROTOCOL_NOW_REGISTERED); + } + + private void compile(String javaHome, Path classesDir) throws Exception { + Files.createDirectories(classesDir); + Path protocolCheck = tempDir.resolve("ProtocolCheck.java"); + writeResource(protocolCheck, "/ProtocolCheck.java"); + Path stubInvoker = tempDir.resolve("FunctionInvoker.java"); + writeResource(stubInvoker, "/StubFunctionInvoker.java"); + Process compile = new ProcessBuilder(Path.of(javaHome, "bin", "javac").toString(), "-d", + classesDir.toString(), protocolCheck.toString(), stubInvoker.toString()) + .redirectErrorStream(true) + .start(); + String compileOutput = new BufferedReader(new InputStreamReader(compile.getInputStream())).lines() + .collect(Collectors.joining("\n")); + boolean finished = compile.waitFor(1, TimeUnit.MINUTES); + if (!finished) { + compile.destroyForcibly(); + } + assertThat(finished).as("javac timed out").isTrue(); + assertThat(compile.exitValue()).as("compilation failed:\n" + compileOutput).isZero(); + } + + private Path createFunctionJar(Path classesDir) throws Exception { + Path functionJar = tempDir.resolve("function.jar"); + try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(functionJar))) { + writeEntry(zip, "META-INF/MANIFEST.MF", "Manifest-Version: 1.0\n".getBytes(StandardCharsets.UTF_8)); + Path loaderJar = codeSource(Launcher.class); + try (JarFile loader = new JarFile(loaderJar.toFile())) { + Enumeration entries = loader.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + if (!entry.isDirectory() && !entry.getName().startsWith("META-INF/")) { + writeEntry(zip, entry.getName(), loader.getInputStream(entry).readAllBytes()); + } + } + } + writeLauncherClasses(zip); + writeEntry(zip, "BOOT-INF/lib/spring-cloud-function-adapter-gcp.jar", nestedAdapterJar(classesDir)); + } + return functionJar; + } + + private void writeLauncherClasses(ZipOutputStream zip) throws Exception { + Path adapterClasses = codeSource(GcfJarLauncher.class); + if (!Files.isDirectory(adapterClasses)) { + throw new IllegalStateException("Adapter classes are not on the classpath as a directory: " + adapterClasses); + } + String launcherPrefix = GCF_LAUNCHER_CLASS_NAME.replace('.', '/'); + try (Stream paths = Files.walk(adapterClasses)) { + for (Path path : paths.toList()) { + if (!Files.isRegularFile(path)) { + continue; + } + String entryName = adapterClasses.relativize(path).toString().replace(File.separatorChar, '/'); + if (entryName.startsWith(launcherPrefix) && entryName.endsWith(".class")) { + writeEntry(zip, entryName, Files.readAllBytes(path)); + } + } + } + } + + private byte[] nestedAdapterJar(Path classesDir) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(bytes)) { + writeEntry(zip, "META-INF/MANIFEST.MF", "Manifest-Version: 1.0\n".getBytes(StandardCharsets.UTF_8)); + Path stub = classesDir.resolve(FUNCTION_INVOKER_CLASS_NAME.replace('.', '/') + ".class"); + writeEntry(zip, FUNCTION_INVOKER_CLASS_NAME.replace('.', '/') + ".class", Files.readAllBytes(stub)); + } + return bytes.toByteArray(); + } + + private static void writeResource(Path target, String resource) throws IOException { + try (InputStream in = Objects.requireNonNull(GcfJarLauncherTests.class.getResourceAsStream(resource), + "Resource " + resource + " not found")) { + Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static Path codeSource(Class type) throws Exception { + return Path.of(type.getProtectionDomain().getCodeSource().getLocation().toURI()); + } + + private static void writeEntry(ZipOutputStream zip, String name, byte[] content) throws IOException { + zip.putNextEntry(new ZipEntry(name)); + zip.write(content); + zip.closeEntry(); + } + +} diff --git a/spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/test/resources/ProtocolCheck.java b/spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/test/resources/ProtocolCheck.java new file mode 100644 index 000000000..4ff64b186 --- /dev/null +++ b/spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/test/resources/ProtocolCheck.java @@ -0,0 +1,54 @@ +import java.io.File; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; + +/** + * Subprocess entry point for {@code GcfJarLauncherTests}. Loads + * {@code GcfJarLauncher} from a Spring Boot fat JAR through a child + * {@link URLClassLoader}, mirroring how the Google Cloud Functions framework + * loads the deployed function JAR, and reports whether the {@code nested:} + * URL protocol is registered before and after the launcher is constructed + * (GH-1336). + */ +public class ProtocolCheck { + + public static void main(String[] args) { + File functionJar = new File(args[0]); + try { + new URL("nested:/dev/null"); + System.out.println("PROTOCOL_ALREADY_REGISTERED"); + } + catch (MalformedURLException ex) { + System.out.println("PROTOCOL_NOT_REGISTERED: " + ex.getMessage()); + } + try (URLClassLoader child = new URLClassLoader(new URL[] { toUrl(functionJar) }, + ClassLoader.getSystemClassLoader())) { + Class launcher = Class.forName("org.springframework.cloud.function.adapter.gcp.GcfJarLauncher", + true, child); + launcher.getConstructor().newInstance(); + System.out.println("GCF_JAR_LAUNCHER_SUCCEEDED"); + } + catch (Throwable ex) { + System.out.println("GCF_JAR_LAUNCHER_FAILED: " + ex); + ex.printStackTrace(System.out); + } + try { + new URL("nested:/dev/null"); + System.out.println("PROTOCOL_NOW_REGISTERED"); + } + catch (MalformedURLException ex) { + System.out.println("PROTOCOL_STILL_NOT_REGISTERED: " + ex.getMessage()); + } + } + + private static URL toUrl(File file) { + try { + return file.toURI().toURL(); + } + catch (MalformedURLException ex) { + throw new IllegalStateException(ex); + } + } + +} diff --git a/spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/test/resources/StubFunctionInvoker.java b/spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/test/resources/StubFunctionInvoker.java new file mode 100644 index 000000000..7e5d44dc9 --- /dev/null +++ b/spring-cloud-function-adapters/spring-cloud-function-adapter-gcp/src/test/resources/StubFunctionInvoker.java @@ -0,0 +1,14 @@ +package org.springframework.cloud.function.adapter.gcp; + +/** + * Minimal stand-in for the real {@code FunctionInvoker} used by + * {@code GcfJarLauncherTests}. The real class would bootstrap the entire + * Spring application in its constructor, which is not needed to verify that + * {@code GcfJarLauncher} can load its delegate from a nested JAR. + */ +public class FunctionInvoker { + + public FunctionInvoker() { + } + +}