You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by cs...@apache.org on 2026/10/12 16:57:35 UTC

(maven-resolver) branch master updated: Allow symlinked local path prefix directories (#2184)

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

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


The following commit(s) were added to refs/heads/master by this push:
     new 05b2e683d Allow symlinked local path prefix directories (#2184)
05b2e683d is described below

commit 05b2e683d558c2f2e7456c03320da76f668f942e
Author: Piotr Findeisen <pi...@gmail.com>
AuthorDate: Mon Oct 12 18:57:29 2026 +0200

    Allow symlinked local path prefix directories (#2184)
    
    The real-path check compared the whole path below the local repository base directory with its symlink-resolved form, so any symbolic link below the base directory made every artifact beneath it "not present". This rejects layouts where a split prefix directory (e.g. the remote cache) is a symbolic link to a cache shared by several local repositories, forcing a re-download of every artifact on each build.
    
    Only the coordinate-derived part of the path can be aliased by case-colliding coordinates; the base directory and the prefix directories come from configuration. Resolve the prefix directory and compare real(prefix directory) + coordinate path with real(full path). Case and normalization aliases in the coordinates are still rejected.
    
    
    
    <!-- Add your description here... what, why and how you resolve the issue -->
    
    - Fixes #2183
    
    ---
    
    To make clear that you license your contribution under
    the [Apache License Version 2.0, January 2004](https://www.apache.org/licenses/LICENSE-2.0)
    check one of the following:
    
    - [x] I hereby declare this contribution to be licensed under the [Apache License Version 2.0, January 2004](https://www.apache.org/licenses/LICENSE-2.0)
    - [x] I hereby declare that I have filed an [Apache Individual Contributor License Agreement](https://www.apache.org/licenses/icla.pdf).
---
 .../impl/EnhancedLocalRepositoryManager.java       | 51 ++++++++++++----------
 .../EnhancedLocalRepositoryManagerFactory.java     | 27 +++++-------
 .../impl/EnhancedLocalRepositoryManagerTest.java   | 12 ++---
 .../EnhancedSplitLocalRepositoryManagerTest.java   | 50 +++++++++++++++++++++
 4 files changed, 95 insertions(+), 45 deletions(-)

diff --git a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/EnhancedLocalRepositoryManager.java b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/EnhancedLocalRepositoryManager.java
index f835c7014..bb6a70535 100644
--- a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/EnhancedLocalRepositoryManager.java
+++ b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/EnhancedLocalRepositoryManager.java
@@ -134,12 +134,6 @@ class EnhancedLocalRepositoryManager extends SimpleLocalRepositoryManager {
      */
     private final ConcurrentHashMap<Path, Properties> trackingFileCache = new ConcurrentHashMap<>();
 
-    /**
-     * Real (symlink-resolved) path of the local repository base directory, used by
-     * {@link #hasFaithfulRealPath(Path)}. It cannot change during the lifetime of this manager.
-     */
-    private final Path realBasePath;
-
     EnhancedLocalRepositoryManager(
             Path basedir,
             LocalPathComposer localPathComposer,
@@ -147,17 +141,13 @@ class EnhancedLocalRepositoryManager extends SimpleLocalRepositoryManager {
             String trackingFilename,
             boolean legacyLocalRepository,
             TrackingFileManager trackingFileManager,
-            LocalPathPrefixComposer localPathPrefixComposer)
-            throws IOException {
+            LocalPathPrefixComposer localPathPrefixComposer) {
         super(basedir, "enhanced", localPathComposer);
         this.trackingRepositoryKeyFunction = requireNonNull(trackingRepositoryKeyFunction);
         this.trackingFilename = requireNonNull(trackingFilename);
         this.legacyLocalRepository = legacyLocalRepository;
         this.trackingFileManager = requireNonNull(trackingFileManager);
         this.localPathPrefixComposer = requireNonNull(localPathPrefixComposer);
-        // a fresh local repository does not exist yet; toRealPath() requires it to
-        Files.createDirectories(getRepository().getBasePath());
-        this.realBasePath = getRepository().getBasePath().toRealPath();
     }
 
     private String concatPaths(String prefix, String artifactPath) {
@@ -218,14 +208,23 @@ class EnhancedLocalRepositoryManager extends SimpleLocalRepositoryManager {
         // Local repository CANNOT have timestamped installed, they are created only during deploy
         if (Objects.equals(artifact.getVersion(), artifact.getBaseVersion())) {
             filePath = getAbsolutePathForLocalArtifact(artifact);
-            checkFind(filePath, result, verifyRealPath);
+            checkFind(
+                    getPrefixDirectory(localPathPrefixComposer.getPathPrefixForLocalArtifact(artifact)),
+                    filePath,
+                    result,
+                    verifyRealPath);
         }
 
         if (!result.isAvailable()) {
             for (RemoteRepository repository : request.getRepositories()) {
                 filePath = getAbsolutePathForRemoteArtifact(artifact, repository, request.getContext());
 
-                checkFind(filePath, result, verifyRealPath);
+                checkFind(
+                        getPrefixDirectory(
+                                localPathPrefixComposer.getPathPrefixForRemoteArtifact(artifact, repository)),
+                        filePath,
+                        result,
+                        verifyRealPath);
 
                 if (result.isAvailable()) {
                     break;
@@ -236,23 +235,29 @@ class EnhancedLocalRepositoryManager extends SimpleLocalRepositoryManager {
         return result;
     }
 
+    private Path getPrefixDirectory(String prefix) {
+        Path basePath = getRepository().getBasePath();
+        return prefix == null || prefix.isEmpty() ? basePath : basePath.resolve(prefix);
+    }
+
     /**
      * Verifies that the real (on-disk) spelling of the given artifact path matches the requested spelling,
-     * relative to the local repository base directory. On case-insensitive or normalization-preserving
+     * relative to the directory of its local path prefix. On case-insensitive or normalization-preserving
      * filesystems (the macOS and Windows defaults) a cached file whose stored name differs from the requested one
      * - for example one cached for case-colliding coordinates - still passes the file-existence check, while the
      * tracking data in the tracking file is compared exactly: the aliased file would then be treated as
      * present-but-untracked and accepted with no download and no checksum verification. Such aliases are treated
-     * as "not present" instead (fail closed), forcing a proper download for the requested coordinates. The
-     * comparison is relative to the (symlink-resolved) base directory, so a symlinked base directory is
-     * supported; symbolic links below the base directory are not - see
+     * as "not present" instead (fail closed), forcing a proper download for the requested coordinates. Only the
+     * coordinate-derived part of the path is compared, as a suffix of the real path: the base directory and the
+     * prefix directories are configuration, not coordinates, so they may be symbolic links - see
      * {@link EnhancedLocalRepositoryManagerFactory#CONFIG_PROP_VERIFY_REAL_PATH} to opt out.
      */
-    private boolean hasFaithfulRealPath(Path path) {
+    private boolean hasFaithfulRealPath(Path prefixDirectory, Path path) {
         try {
-            String requested = getRepository().getBasePath().relativize(path).toString();
-            String real = realBasePath.relativize(path.toRealPath()).toString();
-            if (!requested.equals(real)) {
+            String requested = prefixDirectory.relativize(path).toString();
+            String real = path.toRealPath().toString();
+            // compare strings: Path comparison ignores case on Windows
+            if (!real.endsWith(path.getFileSystem().getSeparator() + requested)) {
                 LOGGER.warn(
                         "Rejecting locally cached artifact {}: its on-disk path {} does not match the requested"
                                 + " coordinates (filesystem case/normalization alias); treating it as not present",
@@ -267,8 +272,8 @@ class EnhancedLocalRepositoryManager extends SimpleLocalRepositoryManager {
         }
     }
 
-    private void checkFind(Path path, LocalArtifactResult result, boolean verifyRealPath) {
-        if (Files.isRegularFile(path) && (!verifyRealPath || hasFaithfulRealPath(path))) {
+    private void checkFind(Path prefixDirectory, Path path, LocalArtifactResult result, boolean verifyRealPath) {
+        if (Files.isRegularFile(path) && (!verifyRealPath || hasFaithfulRealPath(prefixDirectory, path))) {
             result.setPath(path);
 
             Properties props = readRepos(path);
diff --git a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/EnhancedLocalRepositoryManagerFactory.java b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/EnhancedLocalRepositoryManagerFactory.java
index 19d0b931e..26383d20d 100644
--- a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/EnhancedLocalRepositoryManagerFactory.java
+++ b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/EnhancedLocalRepositoryManagerFactory.java
@@ -22,8 +22,6 @@ import javax.inject.Inject;
 import javax.inject.Named;
 import javax.inject.Singleton;
 
-import java.io.IOException;
-
 import org.eclipse.aether.ConfigurationProperties;
 import org.eclipse.aether.RepositorySystemSession;
 import org.eclipse.aether.repository.LocalRepository;
@@ -68,8 +66,9 @@ public class EnhancedLocalRepositoryManagerFactory implements LocalRepositoryMan
      * such an aliased file is treated as present-but-untracked and accepted with no download and no checksum
      * verification, letting case-colliding coordinates poison distinct GAVs. When enabled (the default), an
      * artifact whose on-disk path spelling differs from the requested one is treated as not present, forcing a
-     * proper download. Disable only if the local repository intentionally contains symbolic links below its base
-     * directory (a symlinked base directory itself is supported either way).
+     * proper download. Only the coordinate-derived part of the path is verified, so the base directory and the
+     * local path prefix directories (see {@link DefaultLocalPathPrefixComposerFactory}) may be symbolic links.
+     * Disable only if the local repository intentionally contains symbolic links below those directories.
      *
      * @configurationSource {@link RepositorySystemSession#getConfigProperties()}
      * @configurationType {@link java.lang.Boolean}
@@ -144,18 +143,14 @@ public class EnhancedLocalRepositoryManagerFactory implements LocalRepositoryMan
                 ConfigUtils.getBoolean(session, DEFAULT_LEGACY_LOCAL_REPOSITORY, CONFIG_PROP_LEGACY_LOCAL_REPOSITORY);
 
         if ("".equals(repository.getContentType()) || "default".equals(repository.getContentType())) {
-            try {
-                return new EnhancedLocalRepositoryManager(
-                        repository.getBasePath(),
-                        localPathComposer,
-                        repositoryKeyFunctionFactory.trackingRepositoryKeyFunction(session),
-                        trackingFilename,
-                        legacyLocalRepository,
-                        trackingFileManager,
-                        localPathPrefixComposerFactory.createComposer(session));
-            } catch (IOException e) {
-                throw new NoLocalRepositoryManagerException(repository, e);
-            }
+            return new EnhancedLocalRepositoryManager(
+                    repository.getBasePath(),
+                    localPathComposer,
+                    repositoryKeyFunctionFactory.trackingRepositoryKeyFunction(session),
+                    trackingFilename,
+                    legacyLocalRepository,
+                    trackingFileManager,
+                    localPathPrefixComposerFactory.createComposer(session));
         } else {
             throw new NoLocalRepositoryManagerException(repository);
         }
diff --git a/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/EnhancedLocalRepositoryManagerTest.java b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/EnhancedLocalRepositoryManagerTest.java
index 4a21ab11a..4e71f6ebe 100644
--- a/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/EnhancedLocalRepositoryManagerTest.java
+++ b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/EnhancedLocalRepositoryManagerTest.java
@@ -52,7 +52,7 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue;
 
 public class EnhancedLocalRepositoryManagerTest {
 
-    private Artifact artifact;
+    protected Artifact artifact;
 
     private Artifact snapshot;
 
@@ -63,9 +63,9 @@ public class EnhancedLocalRepositoryManagerTest {
 
     private File artifactFile;
 
-    private RemoteRepository repository;
+    protected RemoteRepository repository;
 
-    private final String testContext = "project/compile";
+    protected final String testContext = "project/compile";
 
     protected TrackingFileManager trackingFileManager;
 
@@ -130,14 +130,14 @@ public class EnhancedLocalRepositoryManagerTest {
         artifact = null;
     }
 
-    private long addLocalArtifact(Artifact artifact) throws IOException {
+    protected long addLocalArtifact(Artifact artifact) throws IOException {
         manager.add(session, new LocalArtifactRegistration(artifact));
         String path = manager.getPathForLocalArtifact(artifact);
 
         return copy(artifact, path);
     }
 
-    private long addRemoteArtifact(Artifact artifact) throws IOException {
+    protected long addRemoteArtifact(Artifact artifact) throws IOException {
         Collection<String> contexts = Arrays.asList(testContext);
         manager.add(session, new LocalArtifactRegistration(artifact, repository, contexts));
         String path = manager.getPathForRemoteArtifact(artifact, repository, testContext);
@@ -255,7 +255,7 @@ public class EnhancedLocalRepositoryManagerTest {
      * Simulates a case-aliasing filesystem (the macOS and Windows defaults) on any filesystem by creating a
      * symbolic link whose name differs from the on-disk file only by case, and returns the aliased artifact.
      */
-    private Artifact createCaseAliasedArtifact() throws Exception {
+    protected Artifact createCaseAliasedArtifact() throws Exception {
         addLocalArtifact(artifact);
 
         Artifact aliased = new DefaultArtifact("gid", "aid", "", "JAR", "1-test");
diff --git a/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/EnhancedSplitLocalRepositoryManagerTest.java b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/EnhancedSplitLocalRepositoryManagerTest.java
index 8d2206185..9b7fadd71 100644
--- a/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/EnhancedSplitLocalRepositoryManagerTest.java
+++ b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/EnhancedSplitLocalRepositoryManagerTest.java
@@ -19,14 +19,21 @@
 package org.eclipse.aether.internal.impl;
 
 import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collections;
 
 import org.eclipse.aether.artifact.Artifact;
 import org.eclipse.aether.artifact.DefaultArtifact;
+import org.eclipse.aether.repository.LocalArtifactRequest;
+import org.eclipse.aether.repository.LocalArtifactResult;
 import org.eclipse.aether.repository.RemoteRepository;
 import org.eclipse.aether.util.repository.RepositoryIdHelper;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
 
 import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
 
 public class EnhancedSplitLocalRepositoryManagerTest extends EnhancedLocalRepositoryManagerTest {
 
@@ -75,4 +82,47 @@ public class EnhancedSplitLocalRepositoryManagerTest extends EnhancedLocalReposi
                 "cached/g/i/d/a.i.d/1.0-SNAPSHOT/a.i.d-1.0-20110329.221805-4.jar",
                 manager.getPathForRemoteArtifact(artifact, remoteRepo, ""));
     }
+
+    /**
+     * Replaces the given prefix directory of the local repository with a symbolic link to a directory elsewhere,
+     * like a local repository sharing its download cache with other local repositories.
+     */
+    private void symlinkPrefixDirectory(String prefix, Path target) {
+        try {
+            Files.createSymbolicLink(basedir.toPath().resolve(prefix), target);
+        } catch (IOException | UnsupportedOperationException e) {
+            assumeTrue(false, "filesystem does not support symbolic links");
+        }
+    }
+
+    @Test
+    void testFindLocalArtifactBelowSymlinkedPrefixDirectory(@TempDir Path target) throws Exception {
+        symlinkPrefixDirectory("installed", target);
+        addLocalArtifact(artifact);
+
+        LocalArtifactRequest request = new LocalArtifactRequest(artifact, null, null);
+        LocalArtifactResult result = manager.find(session, request);
+        assertTrue(result.isAvailable());
+    }
+
+    @Test
+    void testFindRemoteArtifactBelowSymlinkedPrefixDirectory(@TempDir Path target) throws Exception {
+        symlinkPrefixDirectory("cached", target);
+        addRemoteArtifact(artifact);
+
+        LocalArtifactRequest request =
+                new LocalArtifactRequest(artifact, Collections.singletonList(repository), testContext);
+        LocalArtifactResult result = manager.find(session, request);
+        assertTrue(result.isAvailable());
+    }
+
+    @Test
+    void testFindDoesNotAcceptCaseAliasBelowSymlinkedPrefixDirectory(@TempDir Path target) throws Exception {
+        symlinkPrefixDirectory("installed", target);
+        Artifact aliased = createCaseAliasedArtifact();
+
+        LocalArtifactRequest request = new LocalArtifactRequest(aliased, null, null);
+        LocalArtifactResult result = manager.find(session, request);
+        assertFalse(result.isAvailable());
+    }
 }