From 8f355c9e50fab8d80d634003671b3d13e7eb65cc Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 17 Sep 2026 06:16:37 +0200 Subject: [PATCH 1/3] fix(core): Close manifest streams after version detection Disable URL connection caching and close each manifest input stream after parsing to avoid retaining JAR resources during SDK initialization. Add coverage for successful and malformed manifests. Fixes GH-6120 Co-Authored-By: Claude --- .../internal/ManifestVersionReader.java | 99 +++++++++++-------- .../internal/ManifestVersionReaderTest.kt | 78 +++++++++++++++ 2 files changed, 135 insertions(+), 42 deletions(-) create mode 100644 sentry/src/test/java/io/sentry/internal/ManifestVersionReaderTest.kt diff --git a/sentry/src/main/java/io/sentry/internal/ManifestVersionReader.java b/sentry/src/main/java/io/sentry/internal/ManifestVersionReader.java index 3a0161b1879..7478b593b81 100644 --- a/sentry/src/main/java/io/sentry/internal/ManifestVersionReader.java +++ b/sentry/src/main/java/io/sentry/internal/ManifestVersionReader.java @@ -4,7 +4,9 @@ import io.sentry.SentryIntegrationPackageStorage; import io.sentry.util.AutoClosableReentrantLock; import java.io.IOException; +import java.io.InputStream; import java.net.URL; +import java.net.URLConnection; import java.util.Enumeration; import java.util.jar.Attributes; import java.util.jar.Manifest; @@ -18,6 +20,7 @@ public final class ManifestVersionReader { private static final @NotNull AutoClosableReentrantLock staticLock = new AutoClosableReentrantLock(); private volatile boolean hasManifestBeenRead = false; + private final @NotNull ClassLoader classLoader; private final @NotNull VersionInfoHolder versionInfo = new VersionInfoHolder(); private @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); @@ -33,7 +36,13 @@ public final class ManifestVersionReader { return INSTANCE; } - private ManifestVersionReader() {} + private ManifestVersionReader() { + this(ClassLoader.getSystemClassLoader()); + } + + ManifestVersionReader(final @NotNull ClassLoader classLoader) { + this.classLoader = classLoader; + } public @Nullable VersionInfoHolder readOpenTelemetryVersion() { readManifestFiles(); @@ -52,52 +61,58 @@ public void readManifestFiles() { if (hasManifestBeenRead) { return; } - final @NotNull Enumeration resources = - ClassLoader.getSystemClassLoader().getResources("META-INF/MANIFEST.MF"); + final @NotNull Enumeration resources = classLoader.getResources("META-INF/MANIFEST.MF"); while (resources.hasMoreElements()) { try { - final @NotNull Manifest manifest = new Manifest(resources.nextElement().openStream()); - final @Nullable Attributes mainAttributes = manifest.getMainAttributes(); - if (mainAttributes != null) { - final @Nullable String name = mainAttributes.getValue("Sentry-Opentelemetry-SDK-Name"); - final @Nullable String version = mainAttributes.getValue("Implementation-Version"); - final @Nullable String sdkName = mainAttributes.getValue("Sentry-SDK-Name"); - final @Nullable String packageName = mainAttributes.getValue("Sentry-SDK-Package-Name"); + final @NotNull URLConnection connection = resources.nextElement().openConnection(); + connection.setUseCaches(false); + try (final @NotNull InputStream inputStream = connection.getInputStream()) { + final @NotNull Manifest manifest = new Manifest(inputStream); + final @Nullable Attributes mainAttributes = manifest.getMainAttributes(); + if (mainAttributes != null) { + final @Nullable String name = + mainAttributes.getValue("Sentry-Opentelemetry-SDK-Name"); + final @Nullable String version = mainAttributes.getValue("Implementation-Version"); + final @Nullable String sdkName = mainAttributes.getValue("Sentry-SDK-Name"); + final @Nullable String packageName = + mainAttributes.getValue("Sentry-SDK-Package-Name"); - if (name != null && version != null) { - versionInfo.sdkName = name; - versionInfo.sdkVersion = version; - final @Nullable String otelVersion = - mainAttributes.getValue("Sentry-Opentelemetry-Version-Name"); - if (otelVersion != null) { - SentryIntegrationPackageStorage.getInstance() - .addPackage("maven:io.opentelemetry:opentelemetry-sdk", otelVersion); - SentryIntegrationPackageStorage.getInstance().addIntegration("OpenTelemetry"); - } - final @Nullable String otelJavaagentVersion = - mainAttributes.getValue("Sentry-Opentelemetry-Javaagent-Version-Name"); - if (otelJavaagentVersion != null) { - SentryIntegrationPackageStorage.getInstance() - .addPackage( - "maven:io.opentelemetry.javaagent:opentelemetry-javaagent", - otelJavaagentVersion); - SentryIntegrationPackageStorage.getInstance().addIntegration("OpenTelemetry-Agent"); + if (name != null && version != null) { + versionInfo.sdkName = name; + versionInfo.sdkVersion = version; + final @Nullable String otelVersion = + mainAttributes.getValue("Sentry-Opentelemetry-Version-Name"); + if (otelVersion != null) { + SentryIntegrationPackageStorage.getInstance() + .addPackage("maven:io.opentelemetry:opentelemetry-sdk", otelVersion); + SentryIntegrationPackageStorage.getInstance().addIntegration("OpenTelemetry"); + } + final @Nullable String otelJavaagentVersion = + mainAttributes.getValue("Sentry-Opentelemetry-Javaagent-Version-Name"); + if (otelJavaagentVersion != null) { + SentryIntegrationPackageStorage.getInstance() + .addPackage( + "maven:io.opentelemetry.javaagent:opentelemetry-javaagent", + otelJavaagentVersion); + SentryIntegrationPackageStorage.getInstance() + .addIntegration("OpenTelemetry-Agent"); + } + if (name.equals("sentry.java.opentelemetry.agentless")) { + SentryIntegrationPackageStorage.getInstance() + .addIntegration("OpenTelemetry-Agentless"); + } + if (name.equals("sentry.java.opentelemetry.agentless-spring")) { + SentryIntegrationPackageStorage.getInstance() + .addIntegration("OpenTelemetry-Agentless-Spring"); + } } - if (name.equals("sentry.java.opentelemetry.agentless")) { - SentryIntegrationPackageStorage.getInstance() - .addIntegration("OpenTelemetry-Agentless"); - } - if (name.equals("sentry.java.opentelemetry.agentless-spring")) { - SentryIntegrationPackageStorage.getInstance() - .addIntegration("OpenTelemetry-Agentless-Spring"); - } - } - if (sdkName != null - && version != null - && packageName != null - && sdkName.startsWith("sentry.java")) { - SentryIntegrationPackageStorage.getInstance().addPackage(packageName, version); + if (sdkName != null + && version != null + && packageName != null + && sdkName.startsWith("sentry.java")) { + SentryIntegrationPackageStorage.getInstance().addPackage(packageName, version); + } } } } catch (Exception e) { diff --git a/sentry/src/test/java/io/sentry/internal/ManifestVersionReaderTest.kt b/sentry/src/test/java/io/sentry/internal/ManifestVersionReaderTest.kt new file mode 100644 index 00000000000..bd456efe3b2 --- /dev/null +++ b/sentry/src/test/java/io/sentry/internal/ManifestVersionReaderTest.kt @@ -0,0 +1,78 @@ +package io.sentry.internal + +import com.google.common.truth.Truth.assertThat +import java.io.ByteArrayInputStream +import java.net.URL +import java.net.URLConnection +import java.nio.charset.StandardCharsets +import java.util.Collections +import kotlin.test.Test +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class ManifestVersionReaderTest { + private class CloseTrackingInputStream(content: String) : + ByteArrayInputStream(content.toByteArray(StandardCharsets.UTF_8)) { + var isClosed = false + + override fun close() { + isClosed = true + super.close() + } + } + + private class Fixture(contents: List) { + val classLoader = mock() + val inputStreams = contents.map(::CloseTrackingInputStream) + val connections = contents.map { mock() } + val urls = contents.map { mock() } + + init { + whenever(classLoader.getResources("META-INF/MANIFEST.MF")) + .thenReturn(Collections.enumeration(urls)) + urls.indices.forEach { index -> + whenever(urls[index].openConnection()).thenReturn(connections[index]) + whenever(connections[index].inputStream).thenReturn(inputStreams[index]) + } + } + + val sut = ManifestVersionReader(classLoader) + } + + @Test + fun `closes manifest stream and disables connection caching before opening it`() { + val fixture = Fixture(listOf(validManifest())) + + fixture.sut.readManifestFiles() + + assertThat(fixture.inputStreams.single().isClosed).isTrue() + inOrder(fixture.connections.single()) { + verify(fixture.connections.single()).useCaches = false + verify(fixture.connections.single()).inputStream + } + } + + @Test + fun `closes malformed manifest stream and continues reading manifests`() { + val fixture = Fixture(listOf("not a manifest\n", validManifest())) + + val versionInfo = fixture.sut.readOpenTelemetryVersion() + + assertThat(fixture.inputStreams.map { it.isClosed }).containsExactly(true, true).inOrder() + assertThat(versionInfo).isNotNull() + assertThat(versionInfo!!.sdkName).isEqualTo("sentry.java.opentelemetry.test") + assertThat(versionInfo.sdkVersion).isEqualTo("1.2.3") + } + + companion object { + private fun validManifest() = + """ + Manifest-Version: 1.0 + Sentry-Opentelemetry-SDK-Name: sentry.java.opentelemetry.test + Implementation-Version: 1.2.3 + + """ + .trimIndent() + } +} From 2f6cada26aff9b678138e61975e006a4d4fc9f1e Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 17 Sep 2026 06:19:31 +0200 Subject: [PATCH 2/3] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55ef1a8f2c0..2a29c72a3d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ ### Fixes +- Close manifest input streams after version detection to avoid retaining JAR resources ([#6125](https://github.com/getsentry/sentry-java/pull/6125)) - Keep resolving the server name after `Sentry.close()` or a re-init. Closing the SDK shut down the shared hostname cache for the life of the process, so `server_name` silently froze at the value it had last resolved ([#6119](https://github.com/getsentry/sentry-java/pull/6119)) ### Internal From a8f17e550d3ca1b01640ccde356f5d4d218af064 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 17 Sep 2026 13:39:05 +0200 Subject: [PATCH 3/3] chore(core): Clarify manifest resource handling Mark the injected class loader constructor as test-only and document why manifest URL connection caching must remain disabled. Refs GH-6120 Co-Authored-By: Claude --- .../main/java/io/sentry/internal/ManifestVersionReader.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sentry/src/main/java/io/sentry/internal/ManifestVersionReader.java b/sentry/src/main/java/io/sentry/internal/ManifestVersionReader.java index 7478b593b81..753637ccb3e 100644 --- a/sentry/src/main/java/io/sentry/internal/ManifestVersionReader.java +++ b/sentry/src/main/java/io/sentry/internal/ManifestVersionReader.java @@ -13,6 +13,7 @@ import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; @ApiStatus.Internal public final class ManifestVersionReader { @@ -40,6 +41,7 @@ private ManifestVersionReader() { this(ClassLoader.getSystemClassLoader()); } + @TestOnly ManifestVersionReader(final @NotNull ClassLoader classLoader) { this.classLoader = classLoader; } @@ -65,6 +67,7 @@ public void readManifestFiles() { while (resources.hasMoreElements()) { try { final @NotNull URLConnection connection = resources.nextElement().openConnection(); + // Avoid retaining JarFile and inflater resources in the default cache for jar: URLs. connection.setUseCaches(false); try (final @NotNull InputStream inputStream = connection.getInputStream()) { final @NotNull Manifest manifest = new Manifest(inputStream);