You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by sl...@apache.org on 2026/08/09 13:37:23 UTC

(maven-plugin-tools) 01/02: Fix excludedScanDirectories never matching on Windows

This is an automated email from the ASF dual-hosted git repository.

slachiewicz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/maven-plugin-tools.git

commit b791f400d1c424f17968beb0148155ab66390acc
Author: Sylwester Lachiewicz <sl...@apache.org>
AuthorDate: Sun Aug 9 12:18:45 2026 +0200

    Fix excludedScanDirectories never matching on Windows
    
    isExcludedScanDirectory() built a glob straight out of the configured value.
    A backslash is the escape character of the glob syntax and sun.nio.fs.Globs
    applies that on Windows too, so a value interpolated from ${project.basedir}
    or ${project.build.directory} lost every separator: the pattern degenerated to
    "D:aprojtarget" and could never match a real path, and a value ending in a
    separator failed the build outright with a PatternSyntaxException. The feature
    therefore did nothing on Windows for the documented usage.
    
    Compare as paths first, which is separator-, case- and trailing-separator-safe,
    and translate backslashes to forward slashes before building the glob on file
    systems that separate names with a backslash. A forward slash is compiled back
    into the Windows separator, so globs keep working as documented on both
    platforms; on file systems where a backslash is a legal name character it is
    left alone, since there it may be a deliberate escape.
    
    The matching logic moves into a package-private method taking a Path so the
    Windows behaviour can be exercised without running on Windows.
    
    Generated-by: Claude Code (Claude Opus 5, 1M context)
---
 .../tools/plugin/DefaultPluginToolsRequest.java    |  63 ++++++++-
 .../plugin/DefaultPluginToolsRequestTest.java      | 148 +++++++++++++++++++++
 2 files changed, 207 insertions(+), 4 deletions(-)

diff --git a/maven-plugin-tools-api/src/main/java/org/apache/maven/tools/plugin/DefaultPluginToolsRequest.java b/maven-plugin-tools-api/src/main/java/org/apache/maven/tools/plugin/DefaultPluginToolsRequest.java
index e1009aa9..317c354b 100644
--- a/maven-plugin-tools-api/src/main/java/org/apache/maven/tools/plugin/DefaultPluginToolsRequest.java
+++ b/maven-plugin-tools-api/src/main/java/org/apache/maven/tools/plugin/DefaultPluginToolsRequest.java
@@ -21,6 +21,7 @@ package org.apache.maven.tools.plugin;
 import java.io.File;
 import java.net.URI;
 import java.nio.file.FileSystem;
+import java.nio.file.InvalidPathException;
 import java.nio.file.Path;
 import java.util.Collection;
 import java.util.HashSet;
@@ -254,13 +255,67 @@ public class DefaultPluginToolsRequest implements PluginToolsRequest {
 
     @Override
     public boolean isExcludedScanDirectory(File sourceFile) {
-        Path sourcePath = sourceFile.toPath();
-        FileSystem sourceFs = sourcePath.getFileSystem();
-        for (String excludedScanDirectory : getExcludedScanDirectories()) {
-            if (sourceFs.getPathMatcher("glob:" + excludedScanDirectory).matches(sourcePath)) {
+        return isExcluded(sourceFile.toPath(), getExcludedScanDirectories());
+    }
+
+    /**
+     * Determines whether a source directory is covered by any of the configured exclusions.
+     * <p>
+     * Visible for testing: taking the directory as a {@link Path} keeps the whole decision tied to a single
+     * {@link FileSystem}, so the behaviour on Windows separators can be exercised without running on Windows.
+     *
+     * @param sourceDirectory the source directory to check
+     * @param excludedScanDirectories the configured exclusions, either plain directories or globs
+     * @return true if excluded, false otherwise
+     */
+    static boolean isExcluded(Path sourceDirectory, Collection<String> excludedScanDirectories) {
+        FileSystem fileSystem = sourceDirectory.getFileSystem();
+        boolean windowsSeparators = "\\".equals(fileSystem.getSeparator());
+        Path sourcePath = sourceDirectory.toAbsolutePath().normalize();
+        for (String excludedScanDirectory : excludedScanDirectories) {
+            if (excludedScanDirectory == null || excludedScanDirectory.isEmpty()) {
+                // an empty entry would resolve to the working directory and exclude it silently
+                continue;
+            }
+            // The documented form of this parameter is a plain directory, so compare as paths first. That
+            // is safe with respect to separators, trailing separators and case on every file system.
+            try {
+                Path excludedPath = fileSystem
+                        .getPath(excludedScanDirectory)
+                        .toAbsolutePath()
+                        .normalize();
+                if (sourcePath.equals(excludedPath)) {
+                    return true;
+                }
+            } catch (InvalidPathException e) {
+                // not a plain directory - the Windows parser rejects wildcards, for instance - so it can
+                // only ever have been meant as a glob
+            }
+            if (fileSystem
+                    .getPathMatcher("glob:" + toGlobPattern(excludedScanDirectory, windowsSeparators))
+                    .matches(sourcePath)) {
                 return true;
             }
         }
         return false;
     }
+
+    /**
+     * Turns a configured exclusion into a glob pattern.
+     * <p>
+     * Configured values normally come from an interpolated property such as
+     * <code>${project.build.directory}/generated-sources/annotations</code> and therefore hold backslashes on
+     * Windows. A backslash is the escape character of the glob syntax and is swallowed by the pattern compiler
+     * on every platform, so such a value could never match a real path, and one ending in a separator even
+     * failed the build with a {@link java.util.regex.PatternSyntaxException}. A forward slash is compiled back
+     * into the Windows separator, so it is the portable way to spell a separator in a glob.
+     *
+     * @param excludedScanDirectory the configured exclusion
+     * @param windowsSeparators whether the file system separates names with a backslash
+     * @return the pattern to hand to {@link FileSystem#getPathMatcher(String)}
+     */
+    static String toGlobPattern(String excludedScanDirectory, boolean windowsSeparators) {
+        // On a file system that allows backslashes in names they may be a deliberate escape, so leave them be.
+        return windowsSeparators ? excludedScanDirectory.replace('\\', '/') : excludedScanDirectory;
+    }
 }
diff --git a/maven-plugin-tools-api/src/test/java/org/apache/maven/tools/plugin/DefaultPluginToolsRequestTest.java b/maven-plugin-tools-api/src/test/java/org/apache/maven/tools/plugin/DefaultPluginToolsRequestTest.java
new file mode 100644
index 00000000..7f0b80be
--- /dev/null
+++ b/maven-plugin-tools-api/src/test/java/org/apache/maven/tools/plugin/DefaultPluginToolsRequestTest.java
@@ -0,0 +1,148 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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
+ *
+ *   http://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.apache.maven.tools.plugin;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Arrays;
+import java.util.Collections;
+
+import org.apache.maven.plugin.descriptor.PluginDescriptor;
+import org.apache.maven.project.MavenProject;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class DefaultPluginToolsRequestTest {
+
+    private static final Path GENERATED_SOURCES =
+            Paths.get("project", "target", "generated-sources", "annotations").toAbsolutePath();
+
+    private PluginToolsRequest newRequest() {
+        return new DefaultPluginToolsRequest(new MavenProject(), new PluginDescriptor());
+    }
+
+    @Test
+    void aConfiguredSourceRootIsExcluded() {
+        PluginToolsRequest request = newRequest();
+        request.setExcludedScanDirectories(Collections.singleton(GENERATED_SOURCES.toString()));
+
+        assertTrue(request.isExcludedScanDirectory(GENERATED_SOURCES.toFile()));
+    }
+
+    @Test
+    void aTrailingSeparatorStillExcludes() {
+        PluginToolsRequest request = newRequest();
+        request.setExcludedScanDirectories(Collections.singleton(
+                GENERATED_SOURCES + GENERATED_SOURCES.getFileSystem().getSeparator()));
+
+        assertTrue(request.isExcludedScanDirectory(GENERATED_SOURCES.toFile()));
+    }
+
+    @Test
+    void otherSourceRootsAreNotExcluded() {
+        PluginToolsRequest request = newRequest();
+        request.setExcludedScanDirectories(Collections.singleton(GENERATED_SOURCES.toString()));
+
+        assertFalse(request.isExcludedScanDirectory(
+                Paths.get("project", "src", "main", "java").toAbsolutePath().toFile()));
+    }
+
+    @Test
+    void aSiblingOfAnExcludedDirectoryIsNotExcluded() {
+        PluginToolsRequest request = newRequest();
+        request.setExcludedScanDirectories(Collections.singleton(GENERATED_SOURCES.toString()));
+
+        assertFalse(request.isExcludedScanDirectory(
+                Paths.get(GENERATED_SOURCES + "2").toFile()));
+    }
+
+    @Test
+    void anyOfTheConfiguredDirectoriesExcludes() {
+        PluginToolsRequest request = newRequest();
+        request.setExcludedScanDirectories(Arrays.asList(
+                Paths.get("project", "target", "generated-test-sources", "annotations")
+                        .toAbsolutePath()
+                        .toString(),
+                GENERATED_SOURCES.toString()));
+
+        assertTrue(request.isExcludedScanDirectory(GENERATED_SOURCES.toFile()));
+    }
+
+    @Test
+    void nothingIsExcludedByDefault() {
+        assertFalse(newRequest().isExcludedScanDirectory(GENERATED_SOURCES.toFile()));
+    }
+
+    @Test
+    void anEmptyEntryExcludesNothing() {
+        PluginToolsRequest request = newRequest();
+        request.setExcludedScanDirectories(Collections.singleton(""));
+
+        // an empty entry resolves to the working directory, which must not exclude anything
+        assertFalse(
+                request.isExcludedScanDirectory(Paths.get("").toAbsolutePath().toFile()));
+    }
+
+    /**
+     * The regression this class was written for: a configured directory built from an interpolated property
+     * such as {@code ${project.build.directory}} holds Windows separators on Windows, and a backslash is the
+     * escape character of the glob syntax. Left as-is it is swallowed, so the pattern could never match and
+     * the exclusion silently did nothing.
+     */
+    @Test
+    void windowsSeparatorsBecomeGlobSeparators() {
+        assertEquals(
+                "C:/project/target/generated-sources/annotations",
+                DefaultPluginToolsRequest.toGlobPattern("C:\\project\\target\\generated-sources\\annotations", true));
+    }
+
+    @Test
+    void windowsSeparatorsAreTranslatedWithoutDisturbingWildcards() {
+        assertEquals(
+                "C:/project/target/generated-sources/*",
+                DefaultPluginToolsRequest.toGlobPattern("C:\\project\\target\\generated-sources\\*", true));
+    }
+
+    @Test
+    void aTrailingWindowsSeparatorNoLongerBreaksThePattern() {
+        // a glob may not end in an escape character, which used to fail the build with a
+        // PatternSyntaxException rather than merely failing to match
+        assertEquals("C:/project/target/", DefaultPluginToolsRequest.toGlobPattern("C:\\project\\target\\", true));
+    }
+
+    @Test
+    void backslashesAreLeftAloneWhereTheyAreNotSeparators() {
+        // on such a file system a backslash is a legal name character and a deliberate glob escape
+        assertEquals(
+                "/project/target/generated-sources/\\*",
+                DefaultPluginToolsRequest.toGlobPattern("/project/target/generated-sources/\\*", false));
+    }
+
+    @Test
+    void globsStillMatch() {
+        PluginToolsRequest request = newRequest();
+        request.setExcludedScanDirectories(
+                Collections.singleton(GENERATED_SOURCES.getParent().resolve("*").toString()));
+
+        assertTrue(request.isExcludedScanDirectory(GENERATED_SOURCES.toFile()));
+    }
+}