diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e6d660e09..6983ee0e7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - This change is backwards compatible. The default is `null` meaning existing behaviour remains unchanged (setting either `tracesSampleRate` or `tracesSampler` enables performance). - If set to `true`, performance is enabled, even if no `tracesSampleRate` or `tracesSampler` have been configured. - If set to `false` performance is disabled, regardless of `tracesSampleRate` and `tracesSampler` options. +- Detect dependencies by listing MANIFEST.MF files at runtime ([#2538](https://github.com/getsentry/sentry-java/pull/2538)) ## 6.14.0 diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 2453797dc4..6d0025f944 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2439,10 +2439,18 @@ public final class io/sentry/internal/gestures/UiElement$Type : java/lang/Enum { public static fun values ()[Lio/sentry/internal/gestures/UiElement$Type; } +public final class io/sentry/internal/modules/CompositeModulesLoader : io/sentry/internal/modules/ModulesLoader { + public fun (Ljava/util/List;Lio/sentry/ILogger;)V +} + public abstract interface class io/sentry/internal/modules/IModulesLoader { public abstract fun getOrLoadModules ()Ljava/util/Map; } +public final class io/sentry/internal/modules/ManifestModulesLoader : io/sentry/internal/modules/ModulesLoader { + public fun (Lio/sentry/ILogger;)V +} + public abstract class io/sentry/internal/modules/ModulesLoader : io/sentry/internal/modules/IModulesLoader { public static final field EXTERNAL_MODULES_FILENAME Ljava/lang/String; protected final field logger Lio/sentry/ILogger; @@ -3656,6 +3664,11 @@ public abstract class io/sentry/transport/TransportResult { public static fun success ()Lio/sentry/transport/TransportResult; } +public final class io/sentry/util/ClassLoaderUtils { + public fun ()V + public static fun classLoaderOrDefault (Ljava/lang/ClassLoader;)Ljava/lang/ClassLoader; +} + public final class io/sentry/util/CollectionUtils { public static fun filterListEntries (Ljava/util/List;Lio/sentry/util/CollectionUtils$Predicate;)Ljava/util/List; public static fun filterMapEntries (Ljava/util/Map;Lio/sentry/util/CollectionUtils$Predicate;)Ljava/util/Map; diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 7ce1aaeac2..2f8d29209d 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -3,7 +3,9 @@ import io.sentry.cache.EnvelopeCache; import io.sentry.cache.IEnvelopeCache; import io.sentry.config.PropertiesProviderFactory; +import io.sentry.internal.modules.CompositeModulesLoader; import io.sentry.internal.modules.IModulesLoader; +import io.sentry.internal.modules.ManifestModulesLoader; import io.sentry.internal.modules.NoOpModulesLoader; import io.sentry.internal.modules.ResourcesModulesLoader; import io.sentry.protocol.SentryId; @@ -15,6 +17,7 @@ import io.sentry.util.thread.NoOpMainThreadChecker; import java.io.File; import java.lang.reflect.InvocationTargetException; +import java.util.Arrays; import java.util.List; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -276,10 +279,14 @@ private static boolean initConfigurations(final @NotNull SentryOptions options) }); } - final IModulesLoader modulesLoader = options.getModulesLoader(); - // only override the ModulesLoader if it's not already set by Android + final @NotNull IModulesLoader modulesLoader = options.getModulesLoader(); if (modulesLoader instanceof NoOpModulesLoader) { - options.setModulesLoader(new ResourcesModulesLoader(options.getLogger())); + options.setModulesLoader( + new CompositeModulesLoader( + Arrays.asList( + new ManifestModulesLoader(options.getLogger()), + new ResourcesModulesLoader(options.getLogger())), + options.getLogger())); } final IMainThreadChecker mainThreadChecker = options.getMainThreadChecker(); diff --git a/sentry/src/main/java/io/sentry/config/ClasspathPropertiesLoader.java b/sentry/src/main/java/io/sentry/config/ClasspathPropertiesLoader.java index 1a730e8f16..58abb277b0 100644 --- a/sentry/src/main/java/io/sentry/config/ClasspathPropertiesLoader.java +++ b/sentry/src/main/java/io/sentry/config/ClasspathPropertiesLoader.java @@ -1,5 +1,7 @@ package io.sentry.config; +import static io.sentry.util.ClassLoaderUtils.classLoaderOrDefault; + import io.sentry.ILogger; import io.sentry.SentryLevel; import java.io.BufferedInputStream; @@ -18,12 +20,7 @@ final class ClasspathPropertiesLoader implements PropertiesLoader { public ClasspathPropertiesLoader( @NotNull String fileName, @Nullable ClassLoader classLoader, @NotNull ILogger logger) { this.fileName = fileName; - // bootstrap classloader is represented as null, so using system classloader instead - if (classLoader == null) { - this.classLoader = ClassLoader.getSystemClassLoader(); - } else { - this.classLoader = classLoader; - } + this.classLoader = classLoaderOrDefault(classLoader); this.logger = logger; } diff --git a/sentry/src/main/java/io/sentry/internal/modules/CompositeModulesLoader.java b/sentry/src/main/java/io/sentry/internal/modules/CompositeModulesLoader.java new file mode 100644 index 0000000000..e1b2698151 --- /dev/null +++ b/sentry/src/main/java/io/sentry/internal/modules/CompositeModulesLoader.java @@ -0,0 +1,36 @@ +package io.sentry.internal.modules; + +import io.sentry.ILogger; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Experimental +@ApiStatus.Internal +public final class CompositeModulesLoader extends ModulesLoader { + + private final List loaders; + + public CompositeModulesLoader( + final @NotNull List loaders, final @NotNull ILogger logger) { + super(logger); + this.loaders = loaders; + } + + @Override + protected Map loadModules() { + final @NotNull TreeMap allModules = new TreeMap<>(); + + for (IModulesLoader loader : this.loaders) { + final @Nullable Map modules = loader.getOrLoadModules(); + if (modules != null) { + allModules.putAll(modules); + } + } + + return allModules; + } +} diff --git a/sentry/src/main/java/io/sentry/internal/modules/ManifestModulesLoader.java b/sentry/src/main/java/io/sentry/internal/modules/ManifestModulesLoader.java new file mode 100644 index 0000000000..1f7ac93950 --- /dev/null +++ b/sentry/src/main/java/io/sentry/internal/modules/ManifestModulesLoader.java @@ -0,0 +1,101 @@ +package io.sentry.internal.modules; + +import static io.sentry.util.ClassLoaderUtils.classLoaderOrDefault; + +import io.sentry.ILogger; +import io.sentry.SentryLevel; +import java.net.URL; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Experimental +@ApiStatus.Internal +public final class ManifestModulesLoader extends ModulesLoader { + private final Pattern URL_LIB_PATTERN = Pattern.compile(".*/(.+)!/META-INF/MANIFEST.MF"); + private final Pattern NAME_AND_VERSION = Pattern.compile("(.*?)-(\\d+\\.\\d+.*).jar"); + private final ClassLoader classLoader; + + public ManifestModulesLoader(final @NotNull ILogger logger) { + this(ManifestModulesLoader.class.getClassLoader(), logger); + } + + ManifestModulesLoader(final @Nullable ClassLoader classLoader, final @NotNull ILogger logger) { + super(logger); + this.classLoader = classLoaderOrDefault(classLoader); + } + + @Override + protected Map loadModules() { + final @NotNull Map modules = new HashMap<>(); + List detectedModules = detectModulesViaManifestFiles(); + + for (Module module : detectedModules) { + modules.put(module.name, module.version); + } + + return modules; + } + + private @NotNull List detectModulesViaManifestFiles() { + final @NotNull List modules = new ArrayList<>(); + try { + final @NotNull Enumeration manifestUrls = + classLoader.getResources("META-INF/MANIFEST.MF"); + while (manifestUrls.hasMoreElements()) { + final @NotNull URL manifestUrl = manifestUrls.nextElement(); + final @Nullable String originalName = extractDependencyNameFromUrl(manifestUrl); + final @Nullable Module module = convertOriginalNameToModule(originalName); + if (module != null) { + modules.add(module); + } + } + } catch (Throwable e) { + logger.log(SentryLevel.ERROR, "Unable to detect modules via manifest files.", e); + } + + return modules; + } + + private @Nullable Module convertOriginalNameToModule(@Nullable String originalName) { + if (originalName == null) { + return null; + } + + final @NotNull Matcher matcher = NAME_AND_VERSION.matcher(originalName); + if (matcher.matches() && matcher.groupCount() == 2) { + @NotNull String moduleName = matcher.group(1); + @NotNull String moduleVersion = matcher.group(2); + return new Module(moduleName, moduleVersion); + } + + return null; + } + + private @Nullable String extractDependencyNameFromUrl(final @NotNull URL url) { + final @NotNull String urlString = url.toString(); + final @NotNull Matcher matcher = URL_LIB_PATTERN.matcher(urlString); + if (matcher.matches() && matcher.groupCount() == 1) { + return matcher.group(1); + } + + return null; + } + + private static final class Module { + private final @NotNull String name; + private final @NotNull String version; + + public Module(final @NotNull String name, final @NotNull String version) { + this.name = name; + this.version = version; + } + } +} diff --git a/sentry/src/main/java/io/sentry/internal/modules/ResourcesModulesLoader.java b/sentry/src/main/java/io/sentry/internal/modules/ResourcesModulesLoader.java index 2b26a284d4..11504cc0cc 100644 --- a/sentry/src/main/java/io/sentry/internal/modules/ResourcesModulesLoader.java +++ b/sentry/src/main/java/io/sentry/internal/modules/ResourcesModulesLoader.java @@ -1,5 +1,7 @@ package io.sentry.internal.modules; +import static io.sentry.util.ClassLoaderUtils.classLoaderOrDefault; + import io.sentry.ILogger; import io.sentry.SentryLevel; import java.io.InputStream; @@ -20,12 +22,7 @@ public ResourcesModulesLoader(final @NotNull ILogger logger) { ResourcesModulesLoader(final @NotNull ILogger logger, final @Nullable ClassLoader classLoader) { super(logger); - // bootstrap classloader is represented as null, so using system classloader instead - if (classLoader == null) { - this.classLoader = ClassLoader.getSystemClassLoader(); - } else { - this.classLoader = classLoader; - } + this.classLoader = classLoaderOrDefault(classLoader); } @Override diff --git a/sentry/src/main/java/io/sentry/util/ClassLoaderUtils.java b/sentry/src/main/java/io/sentry/util/ClassLoaderUtils.java new file mode 100644 index 0000000000..e0a069630a --- /dev/null +++ b/sentry/src/main/java/io/sentry/util/ClassLoaderUtils.java @@ -0,0 +1,16 @@ +package io.sentry.util; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class ClassLoaderUtils { + + public static @NotNull ClassLoader classLoaderOrDefault(final @Nullable ClassLoader classLoader) { + // bootstrap classloader is represented as null, so using system classloader instead + if (classLoader == null) { + return ClassLoader.getSystemClassLoader(); + } else { + return classLoader; + } + } +} diff --git a/sentry/src/test/java/io/sentry/SentryTest.kt b/sentry/src/test/java/io/sentry/SentryTest.kt index f6258fcf2b..11e3b55ce4 100644 --- a/sentry/src/test/java/io/sentry/SentryTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTest.kt @@ -2,8 +2,8 @@ package io.sentry import io.sentry.cache.EnvelopeCache import io.sentry.cache.IEnvelopeCache +import io.sentry.internal.modules.CompositeModulesLoader import io.sentry.internal.modules.IModulesLoader -import io.sentry.internal.modules.ResourcesModulesLoader import io.sentry.protocol.SentryId import io.sentry.util.thread.IMainThreadChecker import io.sentry.util.thread.MainThreadChecker @@ -309,7 +309,7 @@ class SentryTest { sentryOptions = it } - assertTrue { sentryOptions!!.modulesLoader is ResourcesModulesLoader } + assertTrue { sentryOptions!!.modulesLoader is CompositeModulesLoader } } @Test diff --git a/sentry/src/test/java/io/sentry/internal/modules/CompositeModulesLoaderTest.kt b/sentry/src/test/java/io/sentry/internal/modules/CompositeModulesLoaderTest.kt new file mode 100644 index 0000000000..d7812df974 --- /dev/null +++ b/sentry/src/test/java/io/sentry/internal/modules/CompositeModulesLoaderTest.kt @@ -0,0 +1,45 @@ +package io.sentry.internal.modules + +import io.sentry.ILogger +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever +import kotlin.test.Test +import kotlin.test.assertEquals + +class CompositeModulesLoaderTest { + + @Test + fun `reads modules from multiple loaders and caches result`() { + val logger = mock() + val loader1 = mock() + val loader2 = mock() + + whenever(loader1.orLoadModules).thenReturn(mapOf("spring-core" to "6.0.0")) + whenever(loader2.orLoadModules).thenReturn(mapOf("spring-webmvc" to "6.0.2")) + + val sut = CompositeModulesLoader(listOf(loader1, loader2), logger) + + assertEquals( + mapOf( + "spring-core" to "6.0.0", + "spring-webmvc" to "6.0.2" + ), + sut.orLoadModules + ) + + verify(loader1).orLoadModules + verify(loader2).orLoadModules + + assertEquals( + mapOf( + "spring-core" to "6.0.0", + "spring-webmvc" to "6.0.2" + ), + sut.orLoadModules + ) + + verifyNoMoreInteractions(loader1, loader2) + } +} diff --git a/sentry/src/test/java/io/sentry/internal/modules/ManifestModulesLoaderTest.kt b/sentry/src/test/java/io/sentry/internal/modules/ManifestModulesLoaderTest.kt new file mode 100644 index 0000000000..0ad30dc090 --- /dev/null +++ b/sentry/src/test/java/io/sentry/internal/modules/ManifestModulesLoaderTest.kt @@ -0,0 +1,85 @@ +package io.sentry.internal.modules + +import io.sentry.ILogger +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever +import java.io.IOException +import java.net.URL +import java.util.Collections +import kotlin.test.Test +import kotlin.test.assertEquals + +class ManifestModulesLoaderTest { + + @Test + fun `reads modules from manifest urls caches result`() { + val logger = mock() + val classLoader = mock() + + whenever(classLoader.getResources(any())).thenReturn( + Collections.enumeration( + listOf( + URL("jar:file:/Users/sentry/.gradle/caches/modules-2/files-2.1/org.springframework.boot/spring-boot-starter-security/3.0.0/efe7ffae5c9875e2019c6a701759ea524cb331ee/spring-boot-starter-security-3.0.0.jar!/META-INF/MANIFEST.MF"), + URL("jar:file:/Users/sentry/.gradle/caches/modules-2/files-2.1/org.yaml/snakeyaml/1.33/2cd0a87ff7df953f810c344bdf2fe3340b954c69/snakeyaml-1.33.jar!/META-INF/MANIFEST.MF"), + URL("jar:file:/usr/local/tomcat/webapps/ROOT/WEB-INF/lib/aspectjweaver-1.9.9.1.jar!/META-INF/MANIFEST.MF"), + URL("jar:file:/Users/sentry/repos/sentry-java/sentry-samples/sentry-samples-spring-boot-jakarta/build/libs/sentry-samples-spring-boot-jakarta-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/kotlin-stdlib-jdk8-1.6.10.jar!/META-INF/MANIFEST.MF"), + URL("http://sentry.io"), + URL("jar:file:/Users/sentry/repos/sentry-java/sentry-samples/sentry-samples-spring-boot-jakarta/build/libs/sentry-samples-spring-boot-jakarta-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/hello-world.jar!/META-INF/MANIFEST.MF") + ) + ) + ) + + val sut = ManifestModulesLoader(classLoader, logger) + + assertEquals( + mapOf( + "spring-boot-starter-security" to "3.0.0", + "snakeyaml" to "1.33", + "aspectjweaver" to "1.9.9.1", + "kotlin-stdlib-jdk8" to "1.6.10" + ), + sut.orLoadModules + ) + + verify(classLoader).getResources(any()) + + assertEquals( + mapOf( + "spring-boot-starter-security" to "3.0.0", + "snakeyaml" to "1.33", + "aspectjweaver" to "1.9.9.1", + "kotlin-stdlib-jdk8" to "1.6.10" + ), + sut.orLoadModules + ) + + verifyNoMoreInteractions(classLoader) + } + + @Test + fun `reading modules from manifest returns empty map on IOException`() { + val logger = mock() + val classLoader = mock() + + whenever(classLoader.getResources(any())).thenThrow(IOException("thrown on purpose")) + + val sut = ManifestModulesLoader(classLoader, logger) + + assertEquals( + emptyMap(), + sut.orLoadModules + ) + + verify(classLoader).getResources(any()) + + assertEquals( + emptyMap(), + sut.orLoadModules + ) + + verifyNoMoreInteractions(classLoader) + } +}