Skip to content

ClasspathSqlMigrationScanner.matchesPath NPEs under classloaders returning null for getResource(".") #4241

@ozgliderpilot

Description

@ozgliderpilot

Hi.
I'm building flyway-mongodb extension for quakus. Because I love quarkus, mongodb and flyway, and I want more people to use this combination seamlessly.

During implementation I faced an NullPointerException under quakus classloader which originates in ClasspathSqlMigrationScanner.

 2026-05-21 09:23:46,677 ERROR [io.quarkus.runtime.Application] (Quarkus Main Thread) Failed to start application: java.lang.RuntimeException: Failed to start quarkus
        at io.quarkus.runner.ApplicationImpl.doStart(Unknown Source)
        at io.quarkus.runtime.Application.start(Application.java:112)
        at io.quarkus.runtime.ApplicationLifecycleManager.run(ApplicationLifecycleManager.java:127)
        at io.quarkus.runtime.Quarkus.run(Quarkus.java:79)
        at io.quarkus.runtime.Quarkus.run(Quarkus.java:50)
        at io.quarkus.runtime.Quarkus.run(Quarkus.java:143)
        at io.quarkus.runner.GeneratedMain.main(Unknown Source)
  Caused by: org.flywaydb.core.api.FlywayException: Cannot invoke "java.net.URL.getPath()" because the return value of "java.lang.ClassLoader.getResource(String)" is null
        at org.flywaydb.nc.preparation.PreparationContext.getFromFuture(PreparationContext.java:159)
        at org.flywaydb.nc.preparation.PreparationContext.initialize(PreparationContext.java:76)
        at org.flywaydb.nc.preparation.PreparationContext.get(PreparationContext.java:148)
        at org.flywaydb.verb.migrate.MigrateVerbExtension.executeVerb(MigrateVerbExtension.java:70)
        at org.flywaydb.core.Flyway.migrate(Flyway.java:178)
        at io.quarkus.flyway.mongodb.runtime.FlywayMongodbRecorder.doStartActions(FlywayMongodbRecorder.java:137)
  Caused by: java.lang.NullPointerException: Cannot invoke "java.net.URL.getPath()" because the return value of "java.lang.ClassLoader.getResource(String)" is null
        at org.flywaydb.scanners.ClasspathSqlMigrationScanner.matchesPath(ClasspathSqlMigrationScanner.java:115)
        at org.flywaydb.scanners.BaseSqlMigrationScanner.lambda$scanFromFileSystem$0(BaseSqlMigrationScanner.java:93)
        at org.flywaydb.scanners.BaseSqlMigrationScanner.scanFromFileSystem(BaseSqlMigrationScanner.java:95)
        at org.flywaydb.scanners.ClasspathSqlMigrationScanner.scan(ClasspathSqlMigrationScanner.java:98)
        at org.flywaydb.nc.migration.MigrationScannerManager.lambda$scan$4(MigrationScannerManager.java:142)
        at org.flywaydb.nc.migration.MigrationScannerManager.scan(MigrationScannerManager.java:143)
        at org.flywaydb.nc.migration.MigrationScannerManager.lambda$scan$0(MigrationScannerManager.java:64)
        at org.flywaydb.nc.migration.MigrationScannerManager.scan(MigrationScannerManager.java:66)
        at org.flywaydb.nc.utils.VerbUtils.scanForResources(VerbUtils.java:68)
        at org.flywaydb.nc.preparation.PreparationContext.lambda$initialize$0(PreparationContext.java:71)
        at java.base/java.util.concurrent.CompletableFuture$AsyncSupply.run(CompletableFuture.java:1789)

Root cause analysis

ClasspathSqlMigrationScanner#matchesPath (flyway-nc-scanners 12.0.0) dereferences Thread.currentThread().getContextClassLoader().getResource(".") without a null check:

   final String rootPath = new File(Thread.currentThread()
      .getContextClassLoader()
      .getResource(".")              // null under several classloaders
      .getPath()).getAbsolutePath();

ClassLoader.getResource(".") is not contractual. It returns null under QuarkusClassLoader, OSGi framework loaders (Equinox / Felix), and JDK module-layer loaders, and is undefined on the JDK system loader for multi-entry classpaths (returns only the first entry).

This reaches my extension via flyway-database-nc-mongodb because the NC pipeline (MigrationScannerManager.scan in flyway-nc-core) runs every registered NativeConnectorsMigrationScanner without consulting Configuration.getResourceProvider(), unlike FlywayExecutor.createResourceAndClassProviders in the classic pipeline, which lets embedders bypass scanning entirely. More details can be found in my quarkus PR if needed.

Solution options

Option A: Just fix the NPE

rootPath is only used in ClasspathSqlMigrationScanner#matchesPath to compute remainingPath = path.substring(rootPath.length() + 1). The caller BaseSqlMigrationScanner.scanFromFileSystem(File dir, Location, …) already has the on-disk root for that Location. The solution threads dir through to a new overload of matchesPath that derives the relative path from it, without changing the existing 2-arg abstract method — so the fix is strictly binary- and source-compatible.

org.flywaydb.scanners.BaseSqlMigrationScanner:

// UNCHANGED — still the abstract entry point. Existing subclasses keep
// compiling and linking against the old class file with no source change.
abstract boolean matchesPath(String path, Location location);

// NEW — non-abstract 3-arg overload. Default delegates to the legacy
// form, so subclasses that don't override it inherit today's behaviour.
boolean matchesPath(String path, Location location, File rootDir) {
    return matchesPath(path, location);
}

// scanFromFileSystem now calls the 3-arg form, passing dir through:
protected Collection<Pair<LoadableResource, SqlScriptMetadata>> scanFromFileSystem(
        final File dir, final Location location, …) {
    …
    return resourceNames.stream()
        .filter(path -> matchesPath(path, location, dir))   // was matchesPath(path, location)
        …
}

org.flywaydb.scanners.ClasspathSqlMigrationScanner:

// Override the new 3-arg form. The legacy 2-arg body stays as-is but
// is no longer reached from Flyway's own scanning pipeline, so its
// NPE is dead code unless a third-party caller invokes it directly.
@Override
boolean matchesPath(final String path, final Location location, final File rootDir) {
    final Path relative = rootDir.toPath().relativize(Paths.get(path));
    final String classpathRelative = (location.getRootPath() + "/" + relative)
        .replace("\\", "/");
    return matchesAnyWildcardRestrictions(location, classpathRelative);
}

FileSystemSqlMigrationScanner is unaffected — it already doesn't call getResource("."), and it inherits the new 3-arg default which delegates back to its existing 2-arg body. It can adopt the 3-arg override later for symmetry, but that's optional.

~12 lines, strict semantic improvement on every classloader, no behavioural change on the JDK-loader / single-classpath-entry happy path (the unchanged 2-arg body still runs for any scanner that doesn't override the 3-arg form). The descriptor, visibility, and abstractness of the
existing matchesPath(String, Location) are unchanged, so binary and source compatibility are preserved for any subclass of BaseSqlMigrationScanner against the old jar.

Option B: Harmonise flyway-nc with it jdbs version by honouring ResourceProvider in the NC pipeline

Option A fixes the symptom. The deeper asymmetry is that MigrationScannerManager.scan ignores Configuration.getResourceProvider() entirely, even though FlywayExecutor.createResourceAndClassProviders in the classic pipeline already honours it. Adding the same gate would let embedders skip NC classpath scanning the way they can in the classic pipeline:

  // MigrationScannerManager.scan(...)
  public Collection<LoadableResourceMetadata> scan(
          final Configuration configuration,
          final ParsingContext parsingContext,
          final Location[] locations) {
      final ResourceProvider provider = configuration.getResourceProvider();
      if (provider != null) {
          return resourcesFromProvider(provider, configuration, parsingContext, locations);
      }
      // existing per-scanner flat-map …
  }

Pros: brings NC behaviour in line with the classic pipeline; fixes the same class of bug for all embedders on any non-conforming classloader (Quarkus, OSGi, modular JDK runners, custom enterprise loaders) without touching matchesPath at all; would let the Quarkus MongoDB extension reuse the JDBC-side QuarkusFlywayResourceProvider verbatim.

Help offered

I'm happy to open a PR for A, B, or both. Just let me know.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions