From 0962025c4b8c08eef1023aa5c06768dfb8bf022e Mon Sep 17 00:00:00 2001 From: Scott Frederick Date: Wed, 20 Mar 2024 10:33:35 -0500 Subject: [PATCH] Support loading of base64 encoded values as Resources An ApplicationResourceLoader has been introduced to support loading resources using registered ProtocolResolvers. All usages of DefaultResourceLoader and ResourceUtils have been changed to use the ApplicationResourceLoader. A Base64ProtocolResolver has been added to support resources of type `base64:` that contain base64 encoded values. Closes gh-36033 --- .../build/architecture/ArchitectureCheck.java | 24 +++++- .../architecture/ArchitectureCheckTests.java | 19 ++++- .../loads/ResourceUtilsResourceLoader.java | 29 ++++++++ .../noloads/ResourceUtilsWithoutLoading.java | 32 ++++++++ .../ssl/BundleContentProperty.java | 22 +++--- .../ssl/BundleContentPropertyTests.java | 13 ++-- .../boot/convert/StringToFileConverter.java | 20 ++--- .../boot/io/ApplicationResourceLoader.java | 73 +++++++++++++++++++ .../boot/io/Base64ProtocolResolver.java | 51 +++++++++++++ .../boot/io/ProtocolResolvers.java | 45 ++++++++++++ .../springframework/boot/io/package-info.java | 20 +++++ .../boot/logging/java/JavaLoggingSystem.java | 9 ++- .../logging/log4j2/Log4J2LoggingSystem.java | 16 ++-- .../logging/logback/LogbackLoggingSystem.java | 8 +- .../boot/ssl/jks/JksSslStoreBundle.java | 10 ++- .../boot/ssl/pem/PemContent.java | 17 ++--- .../main/resources/META-INF/spring.factories | 4 + .../convert/StringToFileConverterTests.java | 12 ++- .../boot/io/Base64ProtocolResolverTests.java | 60 +++++++++++++++ .../boot/ssl/jks/JksSslStoreBundleTests.java | 43 ++++++++++- 20 files changed, 458 insertions(+), 69 deletions(-) create mode 100644 buildSrc/src/test/java/org/springframework/boot/build/architecture/resources/loads/ResourceUtilsResourceLoader.java create mode 100644 buildSrc/src/test/java/org/springframework/boot/build/architecture/resources/noloads/ResourceUtilsWithoutLoading.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/io/ApplicationResourceLoader.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/io/Base64ProtocolResolver.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/io/ProtocolResolvers.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/io/package-info.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/io/Base64ProtocolResolverTests.java diff --git a/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureCheck.java b/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureCheck.java index d671304708d..c36d46a6750 100644 --- a/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureCheck.java +++ b/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureCheck.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 the original author or authors. + * Copyright 2022-2024 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. @@ -26,12 +26,16 @@ import java.util.List; import java.util.stream.Collectors; import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaCall; import com.tngtech.archunit.core.domain.JavaClass; import com.tngtech.archunit.core.domain.JavaClass.Predicates; import com.tngtech.archunit.core.domain.JavaClasses; import com.tngtech.archunit.core.domain.JavaMethod; import com.tngtech.archunit.core.domain.JavaParameter; import com.tngtech.archunit.core.domain.properties.CanBeAnnotated; +import com.tngtech.archunit.core.domain.properties.HasName; +import com.tngtech.archunit.core.domain.properties.HasOwner.Predicates.With; +import com.tngtech.archunit.core.domain.properties.HasParameterTypes; import com.tngtech.archunit.core.importer.ClassFileImporter; import com.tngtech.archunit.lang.ArchCondition; import com.tngtech.archunit.lang.ArchRule; @@ -58,11 +62,14 @@ import org.gradle.api.tasks.PathSensitivity; import org.gradle.api.tasks.SkipWhenEmpty; import org.gradle.api.tasks.TaskAction; +import org.springframework.util.ResourceUtils; + /** * {@link Task} that checks for architecture problems. * * @author Andy Wilkinson * @author Yanming Zhou + * @author Scott Frederick */ public abstract class ArchitectureCheck extends DefaultTask { @@ -75,7 +82,8 @@ public abstract class ArchitectureCheck extends DefaultTask { allBeanFactoryPostProcessorBeanMethodsShouldBeStaticAndHaveNoParameters(), noClassesShouldCallStepVerifierStepVerifyComplete(), noClassesShouldConfigureDefaultStepVerifierTimeout(), noClassesShouldCallCollectorsToList(), - noClassesShouldCallURLEncoderWithStringEncoding(), noClassesShouldCallURLDecoderWithStringEncoding()); + noClassesShouldCallURLEncoderWithStringEncoding(), noClassesShouldCallURLDecoderWithStringEncoding(), + noClassesShouldLoadResourcesUsingResourceUtils()); getRuleDescriptions().set(getRules().map((rules) -> rules.stream().map(ArchRule::getDescription).toList())); } @@ -208,6 +216,18 @@ public abstract class ArchitectureCheck extends DefaultTask { .because("java.net.URLDecoder.decode(String s, Charset charset) should be used instead"); } + private ArchRule noClassesShouldLoadResourcesUsingResourceUtils() { + return ArchRuleDefinition.noClasses() + .should() + .callMethodWhere(JavaCall.Predicates.target(With.owner(Predicates.type(ResourceUtils.class))) + .and(JavaCall.Predicates.target(HasName.Predicates.name("getURL"))) + .and(JavaCall.Predicates.target(HasParameterTypes.Predicates.rawParameterTypes(String.class))) + .or(JavaCall.Predicates.target(With.owner(Predicates.type(ResourceUtils.class))) + .and(JavaCall.Predicates.target(HasName.Predicates.name("getFile"))) + .and(JavaCall.Predicates.target(HasParameterTypes.Predicates.rawParameterTypes(String.class))))) + .because("org.springframework.boot.io.ApplicationResourceLoader should be used instead"); + } + public void setClasses(FileCollection classes) { this.classes = classes; } diff --git a/buildSrc/src/test/java/org/springframework/boot/build/architecture/ArchitectureCheckTests.java b/buildSrc/src/test/java/org/springframework/boot/build/architecture/ArchitectureCheckTests.java index 1294d619297..085db6c66e1 100644 --- a/buildSrc/src/test/java/org/springframework/boot/build/architecture/ArchitectureCheckTests.java +++ b/buildSrc/src/test/java/org/springframework/boot/build/architecture/ArchitectureCheckTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 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. @@ -36,6 +36,7 @@ import static org.assertj.core.api.Assertions.assertThatExceptionOfType; * Tests for {@link ArchitectureCheck}. * * @author Andy Wilkinson + * @author Scott Frederick */ class ArchitectureCheckTests { @@ -121,6 +122,22 @@ class ArchitectureCheckTests { }); } + @Test + void whenClassLoadsResourceUsingResourceUtilsTaskFailsAndWritesReport() throws Exception { + prepareTask("resources/loads", (architectureCheck) -> { + assertThatExceptionOfType(GradleException.class).isThrownBy(architectureCheck::checkArchitecture); + assertThat(failureReport(architectureCheck)).isNotEmpty(); + }); + } + + @Test + void whenClassUsesResourceUtilsWithoutLoadingResourcesTaskSucceedsAndWritesAnEmptyReport() throws Exception { + prepareTask("resources/noloads", (architectureCheck) -> { + architectureCheck.checkArchitecture(); + assertThat(failureReport(architectureCheck)).isEmpty(); + }); + } + private void prepareTask(String classes, Callback callback) throws Exception { File projectDir = new File(this.temp, "project"); projectDir.mkdirs(); diff --git a/buildSrc/src/test/java/org/springframework/boot/build/architecture/resources/loads/ResourceUtilsResourceLoader.java b/buildSrc/src/test/java/org/springframework/boot/build/architecture/resources/loads/ResourceUtilsResourceLoader.java new file mode 100644 index 00000000000..ce5ff3f61bd --- /dev/null +++ b/buildSrc/src/test/java/org/springframework/boot/build/architecture/resources/loads/ResourceUtilsResourceLoader.java @@ -0,0 +1,29 @@ +/* + * Copyright 2012-2024 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.boot.build.architecture.resources.loads; + +import java.io.FileNotFoundException; + +import org.springframework.util.ResourceUtils; + +public class ResourceUtilsResourceLoader { + + void getResource() throws FileNotFoundException { + ResourceUtils.getURL("gradle.properties"); + } + +} diff --git a/buildSrc/src/test/java/org/springframework/boot/build/architecture/resources/noloads/ResourceUtilsWithoutLoading.java b/buildSrc/src/test/java/org/springframework/boot/build/architecture/resources/noloads/ResourceUtilsWithoutLoading.java new file mode 100644 index 00000000000..98d41edad5d --- /dev/null +++ b/buildSrc/src/test/java/org/springframework/boot/build/architecture/resources/noloads/ResourceUtilsWithoutLoading.java @@ -0,0 +1,32 @@ +/* + * Copyright 2012-2024 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.boot.build.architecture.resources.noloads; + +import java.net.MalformedURLException; +import java.net.URL; + +import org.springframework.util.ResourceUtils; + +public class ResourceUtilsWithoutLoading { + + void inspectResourceLocation() throws MalformedURLException { + ResourceUtils.isUrl("gradle.properties"); + ResourceUtils.isFileURL(new URL("gradle.properties")); + "test".startsWith(ResourceUtils.FILE_URL_PREFIX); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/BundleContentProperty.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/BundleContentProperty.java index 7be8e3eceb3..248f88c86b8 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/BundleContentProperty.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/BundleContentProperty.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 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. @@ -16,13 +16,12 @@ package org.springframework.boot.autoconfigure.ssl; -import java.io.FileNotFoundException; -import java.net.URL; import java.nio.file.Path; +import org.springframework.boot.io.ApplicationResourceLoader; import org.springframework.boot.ssl.pem.PemContent; +import org.springframework.core.io.Resource; import org.springframework.util.Assert; -import org.springframework.util.ResourceUtils; import org.springframework.util.StringUtils; /** @@ -57,9 +56,9 @@ record BundleContentProperty(String name, String value) { private Path toPath() { try { - URL url = toUrl(); - Assert.state(isFileUrl(url), () -> "Value '%s' is not a file URL".formatted(url)); - return Path.of(url.toURI()).toAbsolutePath(); + Resource resource = getResource(); + Assert.state(resource.isFile(), () -> "Value '%s' is not a file resource".formatted(this.value)); + return Path.of(resource.getFile().getAbsolutePath()); } catch (Exception ex) { throw new IllegalStateException("Unable to convert value of property '%s' to a path".formatted(this.name), @@ -67,13 +66,10 @@ record BundleContentProperty(String name, String value) { } } - private URL toUrl() throws FileNotFoundException { + private Resource getResource() { Assert.state(!isPemContent(), "Value contains PEM content"); - return ResourceUtils.getURL(this.value); - } - - private boolean isFileUrl(URL url) { - return "file".equalsIgnoreCase(url.getProtocol()); + ApplicationResourceLoader resourceLoader = new ApplicationResourceLoader(); + return resourceLoader.getResource(this.value); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/BundleContentPropertyTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/BundleContentPropertyTests.java index 72d5f6ae319..1087a980fc5 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/BundleContentPropertyTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/BundleContentPropertyTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 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. @@ -16,10 +16,11 @@ package org.springframework.boot.autoconfigure.ssl; +import java.net.URISyntaxException; +import java.net.URL; import java.nio.file.Path; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalStateException; @@ -37,9 +38,6 @@ class BundleContentPropertyTests { -----END CERTIFICATE----- """; - @TempDir - Path temp; - @Test void isPemContentWhenValueIsPemTextReturnsTrue() { BundleContentProperty property = new BundleContentProperty("name", PEM_TEXT); @@ -78,8 +76,9 @@ class BundleContentPropertyTests { } @Test - void toWatchPathWhenPathReturnsPath() { - Path file = this.temp.toAbsolutePath().resolve("file.txt"); + void toWatchPathWhenPathReturnsPath() throws URISyntaxException { + URL resource = getClass().getResource("keystore.jks"); + Path file = Path.of(resource.toURI()).toAbsolutePath(); BundleContentProperty property = new BundleContentProperty("name", file.toString()); assertThat(property.toWatchPath()).isEqualTo(file); } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/convert/StringToFileConverter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/convert/StringToFileConverter.java index 14a04ff335c..03cc3dd00ff 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/convert/StringToFileConverter.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/convert/StringToFileConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-2024 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. @@ -19,36 +19,26 @@ package org.springframework.boot.convert; import java.io.File; import java.io.IOException; +import org.springframework.boot.io.ApplicationResourceLoader; import org.springframework.core.convert.converter.Converter; -import org.springframework.core.io.DefaultResourceLoader; import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; -import org.springframework.util.ResourceUtils; /** * {@link Converter} to convert from a {@link String} to a {@link File}. Supports basic * file conversion as well as file URLs. * * @author Phillip Webb + * @author Scott Frederick */ class StringToFileConverter implements Converter { - private static final ResourceLoader resourceLoader = new DefaultResourceLoader(null); + private static final ResourceLoader resourceLoader = new ApplicationResourceLoader(); @Override public File convert(String source) { - if (ResourceUtils.isUrl(source)) { - return getFile(resourceLoader.getResource(source)); - } - File file = new File(source); - if (file.exists()) { - return file; - } Resource resource = resourceLoader.getResource(source); - if (resource.exists()) { - return getFile(resource); - } - return file; + return getFile(resource); } private File getFile(Resource resource) { diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/io/ApplicationResourceLoader.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/io/ApplicationResourceLoader.java new file mode 100644 index 00000000000..48fe3ff59c5 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/io/ApplicationResourceLoader.java @@ -0,0 +1,73 @@ +/* + * Copyright 2012-2024 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.boot.io; + +import org.springframework.core.io.ContextResource; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.ProtocolResolver; +import org.springframework.core.io.Resource; + +/** + * A {@link DefaultResourceLoader} with any {@link ProtocolResolver}s registered in a + * {@code spring.factories} file applied to it. Plain paths without a qualifier will + * resolve to file system resources. This is different from {@code DefaultResourceLoader}, + * which resolves unqualified paths to classpath resources. + * + * @author Scott Frederick + * @since 3.3.0 + */ +public class ApplicationResourceLoader extends DefaultResourceLoader { + + /** + * Create a new {@code ApplicationResourceLoader}. + */ + public ApplicationResourceLoader() { + super(); + ProtocolResolvers.applyTo(this); + } + + /** + * Create a new {@code ApplicationResourceLoader}. + * @param classLoader the {@link ClassLoader} to load class path resources with, or + * {@code null} for using the thread context class loader at the time of actual + * resource access + */ + public ApplicationResourceLoader(ClassLoader classLoader) { + super(classLoader); + ProtocolResolvers.applyTo(this); + } + + @Override + protected Resource getResourceByPath(String path) { + return new FileSystemContextResource(path); + } + + private static class FileSystemContextResource extends FileSystemResource implements ContextResource { + + FileSystemContextResource(String path) { + super(path); + } + + @Override + public String getPathWithinContext() { + return getPath(); + } + + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/io/Base64ProtocolResolver.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/io/Base64ProtocolResolver.java new file mode 100644 index 00000000000..f2bc4811e9e --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/io/Base64ProtocolResolver.java @@ -0,0 +1,51 @@ +/* + * Copyright 2012-2024 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.boot.io; + +import java.util.Base64; + +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.ProtocolResolver; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; + +/** + * {@link ProtocolResolver} for resources containing base 64 encoded text. + * + * @author Scott Frederick + */ +class Base64ProtocolResolver implements ProtocolResolver { + + private static final String BASE64_PREFIX = "base64:"; + + @Override + public Resource resolve(String location, ResourceLoader resourceLoader) { + if (location.startsWith(BASE64_PREFIX)) { + return new Base64ByteArrayResource(location.substring(BASE64_PREFIX.length())); + } + return null; + } + + static class Base64ByteArrayResource extends ByteArrayResource { + + Base64ByteArrayResource(String location) { + super(Base64.getDecoder().decode(location.getBytes())); + } + + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/io/ProtocolResolvers.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/io/ProtocolResolvers.java new file mode 100644 index 00000000000..d59ee28297d --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/io/ProtocolResolvers.java @@ -0,0 +1,45 @@ +/* + * Copyright 2012-2024 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.boot.io; + +import java.util.List; + +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.io.ProtocolResolver; +import org.springframework.core.io.support.SpringFactoriesLoader; +import org.springframework.util.Assert; + +/** + * {@link ProtocolResolver} implementations that are loaded from a + * {@code spring.factories} file. + * + * @author Scott Frederick + */ +final class ProtocolResolvers { + + private ProtocolResolvers() { + } + + static void applyTo(T resourceLoader) { + Assert.notNull(resourceLoader, "ResourceLoader must not be null"); + SpringFactoriesLoader loader = SpringFactoriesLoader + .forDefaultResourceLocation(resourceLoader.getClassLoader()); + List resolvers = loader.load(ProtocolResolver.class); + resourceLoader.getProtocolResolvers().addAll(resolvers); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/io/package-info.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/io/package-info.java new file mode 100644 index 00000000000..c06e30d917e --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/io/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2024 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. + */ + +/** + * Support for loading resources. + */ +package org.springframework.boot.io; diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/java/JavaLoggingSystem.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/java/JavaLoggingSystem.java index c28837788e8..4afb84f1735 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/java/JavaLoggingSystem.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/java/JavaLoggingSystem.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 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. @@ -28,6 +28,7 @@ import java.util.logging.Level; import java.util.logging.LogManager; import java.util.logging.Logger; +import org.springframework.boot.io.ApplicationResourceLoader; import org.springframework.boot.logging.AbstractLoggingSystem; import org.springframework.boot.logging.LogFile; import org.springframework.boot.logging.LogLevel; @@ -37,10 +38,10 @@ import org.springframework.boot.logging.LoggingSystem; import org.springframework.boot.logging.LoggingSystemFactory; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; +import org.springframework.core.io.Resource; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; import org.springframework.util.FileCopyUtils; -import org.springframework.util.ResourceUtils; import org.springframework.util.StringUtils; /** @@ -102,8 +103,8 @@ public class JavaLoggingSystem extends AbstractLoggingSystem { protected void loadConfiguration(String location, LogFile logFile) { Assert.notNull(location, "Location must not be null"); try { - String configuration = FileCopyUtils - .copyToString(new InputStreamReader(ResourceUtils.getURL(location).openStream())); + Resource resource = new ApplicationResourceLoader().getResource(location); + String configuration = FileCopyUtils.copyToString(new InputStreamReader(resource.getInputStream())); if (logFile != null) { configuration = configuration.replace("${LOG_FILE}", StringUtils.cleanPath(logFile.toString())); } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/Log4J2LoggingSystem.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/Log4J2LoggingSystem.java index 10152f88c39..4500b4b1324 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/Log4J2LoggingSystem.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/Log4J2LoggingSystem.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 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. @@ -55,6 +55,7 @@ import org.apache.logging.log4j.util.PropertiesUtil; import org.springframework.boot.context.properties.bind.BindResult; import org.springframework.boot.context.properties.bind.Bindable; import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.io.ApplicationResourceLoader; import org.springframework.boot.logging.AbstractLoggingSystem; import org.springframework.boot.logging.LogFile; import org.springframework.boot.logging.LogLevel; @@ -67,10 +68,10 @@ import org.springframework.core.Conventions; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.core.env.Environment; +import org.springframework.core.io.Resource; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; import org.springframework.util.CollectionUtils; -import org.springframework.util.ResourceUtils; import org.springframework.util.StringUtils; /** @@ -300,15 +301,16 @@ public class Log4J2LoggingSystem extends AbstractLoggingSystem { } private Configuration load(String location, LoggerContext context) throws IOException { - URL url = ResourceUtils.getURL(location); - ConfigurationSource source = getConfigurationSource(url); + Resource resource = new ApplicationResourceLoader().getResource(location); + ConfigurationSource source = getConfigurationSource(resource); return ConfigurationFactory.getInstance().getConfiguration(context, source); } - private ConfigurationSource getConfigurationSource(URL url) throws IOException { - if (FILE_PROTOCOL.equals(url.getProtocol())) { - return new ConfigurationSource(url.openStream(), ResourceUtils.getFile(url)); + private ConfigurationSource getConfigurationSource(Resource resource) throws IOException { + if (resource.isFile()) { + return new ConfigurationSource(resource.getInputStream(), resource.getFile()); } + URL url = resource.getURL(); AuthorizationProvider authorizationProvider = ConfigurationFactory .authorizationProvider(PropertiesUtil.getProperties()); SslConfiguration sslConfiguration = url.getProtocol().equals("https") diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogbackLoggingSystem.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogbackLoggingSystem.java index 009da77db76..e5bddb3ab5b 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogbackLoggingSystem.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogbackLoggingSystem.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 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. @@ -50,6 +50,7 @@ import org.springframework.aot.AotDetector; import org.springframework.beans.factory.aot.BeanFactoryInitializationAotContribution; import org.springframework.beans.factory.aot.BeanFactoryInitializationAotProcessor; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.boot.io.ApplicationResourceLoader; import org.springframework.boot.logging.AbstractLoggingSystem; import org.springframework.boot.logging.LogFile; import org.springframework.boot.logging.LogLevel; @@ -62,9 +63,9 @@ import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.core.env.Environment; +import org.springframework.core.io.Resource; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; -import org.springframework.util.ResourceUtils; import org.springframework.util.StringUtils; /** @@ -246,7 +247,8 @@ public class LogbackLoggingSystem extends AbstractLoggingSystem implements BeanF applySystemProperties(initializationContext.getEnvironment(), logFile); } try { - configureByResourceUrl(initializationContext, loggerContext, ResourceUtils.getURL(location)); + Resource resource = new ApplicationResourceLoader().getResource(location); + configureByResourceUrl(initializationContext, loggerContext, resource.getURL()); } catch (Exception ex) { throw new IllegalStateException("Could not initialize Logback logging from " + location, ex); diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/jks/JksSslStoreBundle.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/jks/JksSslStoreBundle.java index 8f475f1c833..e3427ba3d84 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/jks/JksSslStoreBundle.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/jks/JksSslStoreBundle.java @@ -18,17 +18,18 @@ package org.springframework.boot.ssl.jks; import java.io.IOException; import java.io.InputStream; -import java.net.URL; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.NoSuchProviderException; import java.security.cert.CertificateException; +import org.springframework.boot.io.ApplicationResourceLoader; import org.springframework.boot.ssl.SslStoreBundle; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; import org.springframework.core.style.ToStringCreator; import org.springframework.util.Assert; -import org.springframework.util.ResourceUtils; import org.springframework.util.StringUtils; /** @@ -114,8 +115,9 @@ public class JksSslStoreBundle implements SslStoreBundle { private void loadKeyStore(KeyStore store, String location, char[] password) { Assert.state(StringUtils.hasText(location), () -> "Location must not be empty or null"); try { - URL url = ResourceUtils.getURL(location); - try (InputStream stream = url.openStream()) { + ResourceLoader resourceLoader = new ApplicationResourceLoader(); + Resource resource = resourceLoader.getResource(location); + try (InputStream stream = resource.getInputStream()) { store.load(stream, password); } } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemContent.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemContent.java index d3013bcb6f3..7e1f571e9da 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemContent.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemContent.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 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. @@ -19,7 +19,6 @@ package org.springframework.boot.ssl.pem; import java.io.IOException; import java.io.InputStream; import java.io.UncheckedIOException; -import java.net.URL; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -30,8 +29,9 @@ import java.util.List; import java.util.Objects; import java.util.regex.Pattern; +import org.springframework.boot.io.ApplicationResourceLoader; +import org.springframework.core.io.Resource; import org.springframework.util.Assert; -import org.springframework.util.ResourceUtils; import org.springframework.util.StreamUtils; /** @@ -119,7 +119,9 @@ public final class PemContent { return new PemContent(content); } try { - return load(ResourceUtils.getURL(content)); + ApplicationResourceLoader resourceLoader = new ApplicationResourceLoader(); + Resource resource = resourceLoader.getResource(content); + return load(resource.getInputStream()); } catch (IOException | UncheckedIOException ex) { throw new IOException("Error reading certificate or key from file '%s'".formatted(content), ex); @@ -139,13 +141,6 @@ public final class PemContent { } } - private static PemContent load(URL url) throws IOException { - Assert.notNull(url, "Url must not be null"); - try (InputStream in = url.openStream()) { - return load(in); - } - } - private static PemContent load(InputStream in) throws IOException { return of(StreamUtils.copyToString(in, StandardCharsets.UTF_8)); } diff --git a/spring-boot-project/spring-boot/src/main/resources/META-INF/spring.factories b/spring-boot-project/spring-boot/src/main/resources/META-INF/spring.factories index 9cf4aa5ca70..9a4ec6e1e7b 100644 --- a/spring-boot-project/spring-boot/src/main/resources/META-INF/spring.factories +++ b/spring-boot-project/spring-boot/src/main/resources/META-INF/spring.factories @@ -101,3 +101,7 @@ org.springframework.boot.sql.init.dependency.AnnotationDependsOnDatabaseInitiali org.springframework.boot.jdbc.SpringJdbcDependsOnDatabaseInitializationDetector,\ org.springframework.boot.jooq.JooqDependsOnDatabaseInitializationDetector,\ org.springframework.boot.orm.jpa.JpaDependsOnDatabaseInitializationDetector + +# Resource Locator Protocol Resolvers +org.springframework.core.io.ProtocolResolver=\ +org.springframework.boot.io.Base64ProtocolResolver diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/convert/StringToFileConverterTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/convert/StringToFileConverterTests.java index e053f15bc12..a3cb2b37af0 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/convert/StringToFileConverterTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/convert/StringToFileConverterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 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. @@ -17,12 +17,14 @@ package org.springframework.boot.convert; import java.io.File; +import java.io.IOException; import java.util.stream.Stream; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.provider.Arguments; import org.springframework.core.convert.ConversionService; +import org.springframework.core.io.ClassPathResource; import static org.assertj.core.api.Assertions.assertThat; @@ -30,6 +32,7 @@ import static org.assertj.core.api.Assertions.assertThat; * Tests for {@link StringToFileConverter}. * * @author Phillip Webb + * @author Scott Frederick */ class StringToFileConverterTests { @@ -48,6 +51,13 @@ class StringToFileConverterTests { .isEqualTo(new File(this.temp, "test").getAbsoluteFile()); } + @ConversionServiceTest + void convertWhenClasspathPrefixedReturnsFile(ConversionService conversionService) throws IOException { + String resource = new ClassPathResource("test-banner.txt", this.getClass().getClassLoader()).getURL().getFile(); + assertThat(convert(conversionService, "classpath:test-banner.txt").getAbsoluteFile()) + .isEqualTo(new File(resource).getAbsoluteFile()); + } + private File convert(ConversionService conversionService, String source) { return conversionService.convert(source, File.class); } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/io/Base64ProtocolResolverTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/io/Base64ProtocolResolverTests.java new file mode 100644 index 00000000000..606d4c35636 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/io/Base64ProtocolResolverTests.java @@ -0,0 +1,60 @@ +/* + * Copyright 2012-2024 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.boot.io; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.junit.jupiter.api.Test; + +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.io.Resource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link Base64ProtocolResolver}. + * + * @author Scott Frederick + */ +class Base64ProtocolResolverTests { + + @Test + void base64LocationResolves() throws IOException { + String location = Base64.getEncoder().encodeToString("test value".getBytes()); + Resource resource = new Base64ProtocolResolver().resolve("base64:" + location, new DefaultResourceLoader()); + assertThat(resource).isNotNull(); + assertThat(resource.getContentAsString(StandardCharsets.UTF_8)).isEqualTo("test value"); + } + + @Test + void base64LocationWithInvalidBase64ThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy( + () -> new Base64ProtocolResolver().resolve("base64:not valid base64", new DefaultResourceLoader())) + .withMessageContaining("Illegal base64"); + } + + @Test + void locationWithoutPrefixDoesNotResolve() { + Resource resource = new Base64ProtocolResolver().resolve("file:notbase64.txt", new DefaultResourceLoader()); + assertThat(resource).isNull(); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/jks/JksSslStoreBundleTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/jks/JksSslStoreBundleTests.java index 159e98c4565..cbbd14a715d 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/jks/JksSslStoreBundleTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/jks/JksSslStoreBundleTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 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. @@ -16,12 +16,17 @@ package org.springframework.boot.ssl.jks; +import java.io.IOException; +import java.nio.file.Files; import java.security.KeyStore; +import java.util.Base64; import java.util.function.Consumer; import org.junit.jupiter.api.Test; +import org.springframework.boot.io.ApplicationResourceLoader; import org.springframework.boot.web.embedded.test.MockPkcs11Security; +import org.springframework.core.io.Resource; import org.springframework.util.function.ThrowingConsumer; import static org.assertj.core.api.Assertions.assertThat; @@ -122,6 +127,36 @@ class JksSslStoreBundleTests { }).withMessageContaining("com.example.KeyStoreProvider"); } + @Test + void whenLocationsAreBase64Encoded() throws IOException { + JksSslStoreDetails keyStoreDetails = JksSslStoreDetails.forLocation(encodeFileContent("classpath:test.p12")) + .withPassword("secret"); + JksSslStoreDetails trustStoreDetails = JksSslStoreDetails.forLocation(encodeFileContent("classpath:test.jks")) + .withPassword("secret"); + JksSslStoreBundle bundle = new JksSslStoreBundle(keyStoreDetails, trustStoreDetails); + assertThat(bundle.getKeyStore()).satisfies(storeContainingCertAndKey("test-alias", "secret")); + assertThat(bundle.getTrustStore()).satisfies(storeContainingCertAndKey("test-alias", "password")); + } + + @Test + void invalidBase64EncodedLocationThrowsException() { + JksSslStoreDetails keyStoreDetails = JksSslStoreDetails.forLocation("base64:not base 64"); + assertThatIllegalStateException().isThrownBy(() -> new JksSslStoreBundle(keyStoreDetails, null)) + .withMessageContaining("key store") + .withMessageContaining("base64:not base 64") + .havingRootCause() + .isInstanceOf(IllegalArgumentException.class) + .withMessageContaining("Illegal base64"); + } + + @Test + void invalidLocationThrowsException() { + JksSslStoreDetails trustStoreDetails = JksSslStoreDetails.forLocation("does-not-exist.p12"); + assertThatIllegalStateException().isThrownBy(() -> new JksSslStoreBundle(null, trustStoreDetails)) + .withMessageContaining("trust store") + .withMessageContaining("does-not-exist.p12"); + } + private Consumer storeContainingCertAndKey(String keyAlias, String keyPassword) { return storeContainingCertAndKey(KeyStore.getDefaultType(), keyAlias, keyPassword); } @@ -136,4 +171,10 @@ class JksSslStoreBundleTests { }); } + private String encodeFileContent(String location) throws IOException { + Resource resource = new ApplicationResourceLoader().getResource(location); + byte[] bytes = Files.readAllBytes(resource.getFile().toPath()); + return "base64:" + Base64.getEncoder().encodeToString(bytes); + } + }