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

ascheman 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 f4d1fa9cb RRF: self-heal from provably broken auto-discovered prefixes 
files (#1976)
f4d1fa9cb is described below

commit f4d1fa9cb1f036b9128d9617e80e4e6cf0098cc2
Author: Gerd Aschemann <[email protected]>
AuthorDate: Sat Jul 18 15:36:27 2026 +0200

    RRF: self-heal from provably broken auto-discovered prefixes files (#1976)
    
    Verify the first denied path per repository against the repository
    itself (one peek, bounded); if it exists, the auto-discovered prefixes
    file is provably wrong: warn and ignore it for the session. User-provided
    files stay authoritative. Opt-out:
    -Daether.remoteRepositoryFilter.prefixes.verifyDenied=false
    
    Real-world instance: repo.jenkins-ci.org/public serving a leaked
    member-repo prefixes file, breaking every Jenkins-ecosystem build on
    Maven 4 and 3.10 defaults (jenkins-infra/helpdesk#5231).
    
    Refs apache/maven#11856
    
    Co-Authored-By: Claude Fable 5 <[email protected]>
---
 .../PrefixesRemoteRepositoryFilterSource.java      | 157 +++++++++++--
 .../PrefixesRemoteRepositoryFilterSourceTest.java  |  12 +-
 ...moteRepositoryFilterSourceVerifyDeniedTest.java | 260 +++++++++++++++++++++
 .../aether/supplier/RepositorySystemSupplier.java  |   3 +-
 .../aether/supplier/RepositorySystemSupplier.java  |   3 +-
 src/site/markdown/remote-repository-filtering.md   |   2 +
 6 files changed, 416 insertions(+), 21 deletions(-)

diff --git 
a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/filter/PrefixesRemoteRepositoryFilterSource.java
 
b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/filter/PrefixesRemoteRepositoryFilterSource.java
index 1c74819bb..94d536fe5 100644
--- 
a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/filter/PrefixesRemoteRepositoryFilterSource.java
+++ 
b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/filter/PrefixesRemoteRepositoryFilterSource.java
@@ -23,12 +23,14 @@ import javax.inject.Named;
 import javax.inject.Singleton;
 
 import java.net.URI;
+import java.net.URISyntaxException;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.function.Supplier;
 
 import org.eclipse.aether.DefaultRepositorySystemSession;
@@ -48,6 +50,9 @@ import 
org.eclipse.aether.spi.connector.checksum.ChecksumAlgorithmFactory;
 import org.eclipse.aether.spi.connector.filter.RemoteRepositoryFilter;
 import org.eclipse.aether.spi.connector.layout.RepositoryLayout;
 import org.eclipse.aether.spi.connector.layout.RepositoryLayoutProvider;
+import org.eclipse.aether.spi.connector.transport.PeekTask;
+import org.eclipse.aether.spi.connector.transport.Transporter;
+import org.eclipse.aether.spi.connector.transport.TransporterProvider;
 import org.eclipse.aether.spi.remoterepo.RepositoryKeyFunctionFactory;
 import org.eclipse.aether.transfer.NoRepositoryLayoutException;
 import org.eclipse.aether.util.ConfigUtils;
@@ -203,6 +208,30 @@ public final class PrefixesRemoteRepositoryFilterSource 
extends RemoteRepository
 
     public static final boolean DEFAULT_USE_REPOSITORY_MANAGERS = false;
 
+    /**
+     * Configuration to verify the first denied path per remote repository 
when the effective prefixes were
+     * auto-discovered: the denied path existence is checked directly against 
the remote repository, and if the
+     * path exists, the auto-discovered prefixes file is provably wrong (it 
denies content the repository actually
+     * serves) and is dropped for the rest of the session with a warning (the 
filter then behaves as if no input
+     * was available, see {@link #CONFIG_PROP_NO_INPUT_OUTCOME}). If the path 
does not exist, the prefixes file is
+     * consistent with reality for this witness and stays trusted; no further 
verification happens for given remote
+     * repository, keeping the extra cost bounded to at most one existence 
check per remote repository per session.
+     * <p>
+     * This protects builds from broken repository managers that "leak" a 
member repository prefixes file through
+     * a group/virtual repository, silently disabling the whole repository. 
User-provided prefix files are
+     * authoritative and are never verified. Verification is skipped in 
offline mode.
+     *
+     * @since 2.0.21
+     * @configurationSource {@link 
RepositorySystemSession#getConfigProperties()}
+     * @configurationType {@link java.lang.Boolean}
+     * @configurationRepoIdSuffix Yes
+     * @configurationDefaultValue {@link #DEFAULT_VERIFY_DENIED}
+     */
+    public static final String CONFIG_PROP_VERIFY_DENIED =
+            RemoteRepositoryFilterSourceSupport.CONFIG_PROPS_PREFIX + NAME + 
".verifyDenied";
+
+    public static final boolean DEFAULT_VERIFY_DENIED = true;
+
     /**
      * The basedir where to store filter files. If path is relative, it is 
resolved from local repository root.
      *
@@ -227,23 +256,27 @@ public final class PrefixesRemoteRepositoryFilterSource 
extends RemoteRepository
 
     private final RepositoryLayoutProvider repositoryLayoutProvider;
 
+    private final TransporterProvider transporterProvider;
+
     @Inject
     public PrefixesRemoteRepositoryFilterSource(
             RepositoryKeyFunctionFactory repositoryKeyFunctionFactory,
             Supplier<MetadataResolver> metadataResolver,
             Supplier<RemoteRepositoryManager> remoteRepositoryManager,
-            RepositoryLayoutProvider repositoryLayoutProvider) {
+            RepositoryLayoutProvider repositoryLayoutProvider,
+            TransporterProvider transporterProvider) {
         super(repositoryKeyFunctionFactory);
         this.metadataResolver = requireNonNull(metadataResolver);
         this.remoteRepositoryManager = requireNonNull(remoteRepositoryManager);
         this.repositoryLayoutProvider = 
requireNonNull(repositoryLayoutProvider);
+        this.transporterProvider = requireNonNull(transporterProvider);
     }
 
     private static final Object PREFIXES_KEY = 
Keys.of(PrefixesRemoteRepositoryFilterSource.class, "prefixes");
 
     @SuppressWarnings("unchecked")
-    private ConcurrentMap<RemoteRepository, PrefixTree> 
prefixes(RepositorySystemSession session) {
-        return (ConcurrentMap<RemoteRepository, PrefixTree>)
+    private ConcurrentMap<RemoteRepository, CachedPrefixes> 
prefixes(RepositorySystemSession session) {
+        return (ConcurrentMap<RemoteRepository, CachedPrefixes>)
                 session.getData().computeIfAbsent(PREFIXES_KEY, 
ConcurrentHashMap::new);
     }
 
@@ -300,18 +333,54 @@ public final class PrefixesRemoteRepositoryFilterSource 
extends RemoteRepository
         });
     }
 
-    private PrefixTree cachePrefixTree(
+    private CachedPrefixes cachePrefixes(
             RepositorySystemSession session, Path basedir, RemoteRepository 
remoteRepository) {
         return prefixes(session)
                 .computeIfAbsent(
                         normalizeRemoteRepository(session, remoteRepository),
-                        r -> loadPrefixTree(session, basedir, 
remoteRepository));
+                        r -> loadPrefixes(session, basedir, remoteRepository));
     }
 
     private static final PrefixTree DISABLED = new PrefixTree("disabled");
     private static final PrefixTree ENABLED_NO_INPUT = new 
PrefixTree("enabled-no-input");
+    private static final PrefixTree BROKEN = new PrefixTree("broken");
+
+    /**
+     * The cached per remote repository prefixes state: the effective {@link 
PrefixTree}, whether it was
+     * auto-discovered (as only auto-discovered prefixes are subject to denied 
path verification, see
+     * {@link #CONFIG_PROP_VERIFY_DENIED}) and whether verification happened 
already.
+     */
+    private static final class CachedPrefixes {
+        private static final CachedPrefixes DISABLED_PREFIXES = new 
CachedPrefixes(DISABLED, false);
+        private static final CachedPrefixes NO_INPUT_PREFIXES = new 
CachedPrefixes(ENABLED_NO_INPUT, false);
+
+        private volatile PrefixTree prefixTree;
+        private final boolean autoDiscovered;
+        private final AtomicBoolean verifyClaimed = new AtomicBoolean(false);
+
+        private CachedPrefixes(PrefixTree prefixTree, boolean autoDiscovered) {
+            this.prefixTree = prefixTree;
+            this.autoDiscovered = autoDiscovered;
+        }
+
+        private PrefixTree prefixTree() {
+            return prefixTree;
+        }
+
+        private boolean autoDiscovered() {
+            return autoDiscovered;
+        }
 
-    private PrefixTree loadPrefixTree(
+        private boolean claimVerification() {
+            return verifyClaimed.compareAndSet(false, true);
+        }
+
+        private void drop() {
+            this.prefixTree = BROKEN;
+        }
+    }
+
+    private CachedPrefixes loadPrefixes(
             RepositorySystemSession session, Path baseDir, RemoteRepository 
remoteRepository) {
         if (isRepositoryFilteringEnabled(session, remoteRepository)) {
             String origin = "user-provided";
@@ -340,7 +409,7 @@ public final class PrefixesRemoteRepositoryFilterSource 
extends RemoteRepository
                             origin,
                             prefixesSource.origin().getId(),
                             prefixesSource.path().getFileName());
-                    return prefixTree;
+                    return new CachedPrefixes(prefixTree, 
"auto-discovered".equals(origin));
                 } else {
                     logger.info(
                             "Rejected {} prefixes for remote repository {} 
({}): {}",
@@ -351,10 +420,10 @@ public final class PrefixesRemoteRepositoryFilterSource 
extends RemoteRepository
                 }
             }
             logger.debug("Prefix file for remote repository {} not available", 
remoteRepository);
-            return ENABLED_NO_INPUT;
+            return CachedPrefixes.NO_INPUT_PREFIXES;
         }
         logger.debug("Prefix file for remote repository {} disabled", 
remoteRepository);
-        return DISABLED;
+        return CachedPrefixes.DISABLED_PREFIXES;
     }
 
     private Path resolvePrefixesFromLocalConfiguration(
@@ -444,20 +513,33 @@ public final class PrefixesRemoteRepositoryFilterSource 
extends RemoteRepository
         }
 
         private Result acceptPrefix(RemoteRepository repository, String path) {
-            PrefixTree prefixTree = cachePrefixTree(session, basedir, 
repository);
+            CachedPrefixes cachedPrefixes = cachePrefixes(session, basedir, 
repository);
+            PrefixTree prefixTree = cachedPrefixes.prefixTree();
             if (prefixTree == DISABLED) {
                 return result(true, NAME, "Disabled");
             } else if (prefixTree == ENABLED_NO_INPUT) {
-                return result(
-                        ConfigUtils.getBoolean(
-                                session,
-                                DEFAULT_NO_INPUT_OUTCOME,
-                                CONFIG_PROP_NO_INPUT_OUTCOME + "." + 
repository.getId(),
-                                CONFIG_PROP_NO_INPUT_OUTCOME),
-                        NAME,
-                        "No input available");
+                return noInputResult(repository, "No input available");
+            } else if (prefixTree == BROKEN) {
+                return noInputResult(repository, "Broken auto-discovered 
prefixes dropped");
             }
             boolean accepted = prefixTree.acceptedPath(path);
+            if (!accepted && cachedPrefixes.autoDiscovered() && 
isVerifyDeniedEnabled(repository)) {
+                // synchronized: only the first denial is verified; concurrent 
denials wait for the verdict
+                synchronized (cachedPrefixes) {
+                    if (cachedPrefixes.claimVerification() && 
remoteRepositoryServesPath(repository, path)) {
+                        logger.warn(
+                                "Remote repository {} serves a broken prefixes 
file: it denies path {} that the "
+                                        + "repository actually serves; 
ignoring auto-discovered prefixes for this "
+                                        + "repository (report this to the 
repository administrator)",
+                                repository.getId(),
+                                path);
+                        cachedPrefixes.drop();
+                    }
+                }
+                if (cachedPrefixes.prefixTree() == BROKEN) {
+                    return noInputResult(repository, "Broken auto-discovered 
prefixes dropped");
+                }
+            }
             return result(
                     accepted,
                     NAME,
@@ -465,6 +547,45 @@ public final class PrefixesRemoteRepositoryFilterSource 
extends RemoteRepository
                             ? "Path " + path + " allowed from " + 
repository.getId()
                             : "Path " + path + " NOT allowed from " + 
repository.getId());
         }
+
+        private Result noInputResult(RemoteRepository repository, String 
reasoning) {
+            return result(
+                    ConfigUtils.getBoolean(
+                            session,
+                            DEFAULT_NO_INPUT_OUTCOME,
+                            CONFIG_PROP_NO_INPUT_OUTCOME + "." + 
repository.getId(),
+                            CONFIG_PROP_NO_INPUT_OUTCOME),
+                    NAME,
+                    reasoning);
+        }
+
+        private boolean isVerifyDeniedEnabled(RemoteRepository repository) {
+            return !session.isOffline()
+                    && ConfigUtils.getBoolean(
+                            session,
+                            DEFAULT_VERIFY_DENIED,
+                            CONFIG_PROP_VERIFY_DENIED + "." + 
repository.getId(),
+                            CONFIG_PROP_VERIFY_DENIED);
+        }
+
+        /**
+         * Checks whether the remote repository actually serves given path, 
using a lightweight existence check
+         * (the transporter sits below the filtering connector, so no 
recursion can happen). Any failure (path
+         * not present, transport problem) yields {@code false}: the prefixes 
file is dropped only when the
+         * remote repository provably serves the denied path.
+         */
+        private boolean remoteRepositoryServesPath(RemoteRepository 
repository, String path) {
+            try (Transporter transporter = 
transporterProvider.newTransporter(session, repository)) {
+                transporter.peek(new PeekTask(new URI(null, null, path, 
null)));
+                return true;
+            } catch (URISyntaxException e) {
+                logger.debug("Cannot construct URI for denied path {} of {}", 
path, repository, e);
+                return false;
+            } catch (Exception e) {
+                logger.debug("Verification of denied path {} against {} 
failed", path, repository, e);
+                return false;
+            }
+        }
     }
 
     private static final RepositoryLayout NOT_SUPPORTED = new 
RepositoryLayout() {
diff --git 
a/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/filter/PrefixesRemoteRepositoryFilterSourceTest.java
 
b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/filter/PrefixesRemoteRepositoryFilterSourceTest.java
index c469ba903..6cfc96682 100644
--- 
a/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/filter/PrefixesRemoteRepositoryFilterSourceTest.java
+++ 
b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/filter/PrefixesRemoteRepositoryFilterSourceTest.java
@@ -42,6 +42,8 @@ import org.eclipse.aether.resolution.MetadataRequest;
 import org.eclipse.aether.resolution.MetadataResult;
 import org.eclipse.aether.spi.connector.filter.RemoteRepositoryFilter;
 import org.eclipse.aether.spi.connector.filter.RemoteRepositoryFilterSource;
+import org.eclipse.aether.spi.connector.transport.TransporterProvider;
+import org.eclipse.aether.transfer.NoTransporterException;
 import org.junit.jupiter.api.Test;
 
 import static 
org.eclipse.aether.internal.impl.checksum.Checksums.checksumsSelector;
@@ -84,11 +86,19 @@ public class PrefixesRemoteRepositoryFilterSourceTest 
extends RemoteRepositoryFi
                 Maven2RepositoryLayoutFactory.NAME,
                 new Maven2RepositoryLayoutFactory(
                         checksumsSelector(), new 
DefaultArtifactPredicateFactory(checksumsSelector()))));
+        TransporterProvider transporterProvider = 
mock(TransporterProvider.class);
+        try {
+            when(transporterProvider.newTransporter(any(), any()))
+                    .thenThrow(new NoTransporterException(remoteRepository));
+        } catch (NoTransporterException e) {
+            throw new IllegalStateException(e);
+        }
         return new PrefixesRemoteRepositoryFilterSource(
                 new DefaultRepositoryKeyFunctionFactory(),
                 () -> metadataResolver,
                 () -> remoteRepositoryManager,
-                layoutProvider);
+                layoutProvider,
+                transporterProvider);
     }
 
     @Override
diff --git 
a/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/filter/PrefixesRemoteRepositoryFilterSourceVerifyDeniedTest.java
 
b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/filter/PrefixesRemoteRepositoryFilterSourceVerifyDeniedTest.java
new file mode 100644
index 000000000..75433df54
--- /dev/null
+++ 
b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/filter/PrefixesRemoteRepositoryFilterSourceVerifyDeniedTest.java
@@ -0,0 +1,260 @@
+/*
+ * 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.eclipse.aether.internal.impl.filter;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+import org.eclipse.aether.DefaultRepositorySystemSession;
+import org.eclipse.aether.RepositorySystemSession;
+import org.eclipse.aether.artifact.Artifact;
+import org.eclipse.aether.artifact.DefaultArtifact;
+import org.eclipse.aether.impl.MetadataResolver;
+import org.eclipse.aether.impl.RemoteRepositoryManager;
+import org.eclipse.aether.internal.impl.DefaultArtifactPredicateFactory;
+import org.eclipse.aether.internal.impl.DefaultRepositoryKeyFunctionFactory;
+import org.eclipse.aether.internal.impl.DefaultRepositoryLayoutProvider;
+import org.eclipse.aether.internal.impl.Maven2RepositoryLayoutFactory;
+import org.eclipse.aether.internal.test.util.TestUtils;
+import org.eclipse.aether.repository.RemoteRepository;
+import org.eclipse.aether.repository.RepositoryPolicy;
+import org.eclipse.aether.resolution.MetadataRequest;
+import org.eclipse.aether.resolution.MetadataResult;
+import org.eclipse.aether.spi.connector.filter.RemoteRepositoryFilter;
+import org.eclipse.aether.spi.connector.transport.PeekTask;
+import org.eclipse.aether.spi.connector.transport.Transporter;
+import org.eclipse.aether.spi.connector.transport.TransporterProvider;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static 
org.eclipse.aether.internal.impl.checksum.Checksums.checksumsSelector;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * UT for {@link PrefixesRemoteRepositoryFilterSource} behavior when an 
<em>auto-discovered</em> prefixes file is
+ * provably wrong: it denies a path that the remote repository actually 
serves. This is the "broken MRM leaks
+ * member repository prefixes file over virtual repository" scenario from
+ * <a 
href="https://github.com/apache/maven/issues/11856";>apache/maven#11856</a>, 
observed in the wild on
+ * {@code repo.jenkins-ci.org/public} (Artifactory virtual repository serving 
a stale 11-prefix file of a long-gone
+ * member repository, breaking every Jenkins-ecosystem Maven 4 build).
+ * <p>
+ * Expected behavior: a denied path backed by an auto-discovered (i.e. not 
user-authored) prefixes file is verified
+ * once against the remote repository; if the path exists remotely, the 
auto-discovered file is provably broken and
+ * must be dropped for the rest of the session instead of taking the whole 
repository down.
+ */
+public class PrefixesRemoteRepositoryFilterSourceVerifyDeniedTest {
+    /**
+     * Faithful copy of the leaked file served by {@code 
https://repo.jenkins-ci.org/public/.meta/prefixes.txt}
+     * (Last-Modified: 12 Jan 2017): valid magic, valid syntax, but the 
content belongs to a long-gone member
+     * repository — {@code /org/jenkins-ci} is not listed.
+     */
+    private static final String LEAKED_PREFIXES = "## 
repository-prefixes/2.0\n"
+            + "#\n"
+            + "# Prefix file generated by Sonatype Nexus\n"
+            + "# Do not edit, changes will be overwritten!\n"
+            + "/de/regnis\n"
+            + "/com/syntevo\n"
+            + "/com/trilead\n"
+            + "/org/eclipse\n"
+            + "/org/tigris\n"
+            + "/org/tmatesoft\n"
+            + "/org/netbeans\n"
+            + "/org/apache\n"
+            + "/archetype-catalog.xml\n"
+            + "/net/java\n"
+            + "/archetype-catalog.xml.md5\n"
+            + "/archetype-catalog.xml.sha1\n";
+
+    private final Artifact jenkinsArtifact = new 
DefaultArtifact("org.jenkins-ci:version-number:1.14");
+
+    @TempDir
+    private Path tempDir;
+
+    private DefaultRepositorySystemSession session;
+
+    private RemoteRepository remoteRepository;
+
+    private PrefixesRemoteRepositoryFilterSource subject;
+
+    private Transporter transporter;
+
+    @BeforeEach
+    void setup() throws Exception {
+        remoteRepository =
+                new RemoteRepository.Builder("jenkins-public", "default", 
"https://irrelevant.example/";).build();
+        session = TestUtils.newSession();
+
+        Path leakedPrefixesFile = 
tempDir.resolve("prefixes-jenkins-public.txt");
+        Files.write(leakedPrefixesFile, 
LEAKED_PREFIXES.getBytes(StandardCharsets.UTF_8));
+
+        // simulate successful auto-discovery: the remote repository serves 
the leaked file
+        MetadataResolver metadataResolver = mock(MetadataResolver.class);
+        
when(metadataResolver.resolveMetadata(any(RepositorySystemSession.class), 
any(Collection.class)))
+                .thenAnswer(invocation -> {
+                    Collection<MetadataRequest> requests = 
invocation.getArgument(1);
+                    MetadataRequest request = requests.iterator().next();
+                    MetadataResult result = new MetadataResult(request);
+                    
result.setMetadata(request.getMetadata().setPath(leakedPrefixesFile));
+                    return Collections.singletonList(result);
+                });
+        RemoteRepositoryManager remoteRepositoryManager = new 
RemoteRepositoryManager() {
+            @Override
+            public List<RemoteRepository> aggregateRepositories(
+                    RepositorySystemSession session,
+                    List<RemoteRepository> dominantRepositories,
+                    List<RemoteRepository> recessiveRepositories,
+                    boolean recessiveIsRaw) {
+                return recessiveRepositories;
+            }
+
+            @Override
+            public RepositoryPolicy getPolicy(
+                    RepositorySystemSession session, RemoteRepository 
repository, boolean releases, boolean snapshots) {
+                throw new UnsupportedOperationException("not implemented");
+            }
+        };
+        DefaultRepositoryLayoutProvider layoutProvider = new 
DefaultRepositoryLayoutProvider(Collections.singletonMap(
+                Maven2RepositoryLayoutFactory.NAME,
+                new Maven2RepositoryLayoutFactory(
+                        checksumsSelector(), new 
DefaultArtifactPredicateFactory(checksumsSelector()))));
+        // existence checks against the remote repository: peek succeeds 
unless a test says otherwise
+        transporter = mock(Transporter.class);
+        TransporterProvider transporterProvider = 
mock(TransporterProvider.class);
+        when(transporterProvider.newTransporter(any(), 
any())).thenReturn(transporter);
+        subject = new PrefixesRemoteRepositoryFilterSource(
+                new DefaultRepositoryKeyFunctionFactory(),
+                () -> metadataResolver,
+                () -> remoteRepositoryManager,
+                layoutProvider,
+                transporterProvider);
+    }
+
+    @Test
+    void autoDiscoveredPrefixesDenyingExistingPathAreDropped() {
+        RemoteRepositoryFilter filter = 
subject.getRemoteRepositoryFilter(session);
+        assertNotNull(filter);
+
+        // the artifact exists on the remote repository, despite what the 
leaked prefixes file claims;
+        // the provably broken auto-discovered file must not take the 
repository down
+        RemoteRepositoryFilter.Result result = 
filter.acceptArtifact(remoteRepository, jenkinsArtifact);
+
+        assertTrue(
+                result.isAccepted(),
+                "provably broken auto-discovered prefixes must be dropped, 
got: " + result.reasoning());
+    }
+
+    @Test
+    void brokenPrefixesAreDroppedForWholeRepository() throws Exception {
+        RemoteRepositoryFilter filter = 
subject.getRemoteRepositoryFilter(session);
+        assertNotNull(filter);
+
+        assertTrue(filter.acceptArtifact(remoteRepository, 
jenkinsArtifact).isAccepted());
+        // once dropped, other denied paths are accepted as well, without 
further existence checks
+        assertTrue(filter.acceptArtifact(remoteRepository, new 
DefaultArtifact("io.jenkins:other:1.0"))
+                .isAccepted());
+        verify(transporter, times(1)).peek(any(PeekTask.class));
+    }
+
+    @Test
+    void prefixesListedPathStaysAccepted() throws Exception {
+        RemoteRepositoryFilter filter = 
subject.getRemoteRepositoryFilter(session);
+        assertNotNull(filter);
+
+        RemoteRepositoryFilter.Result result =
+                filter.acceptArtifact(remoteRepository, new 
DefaultArtifact("org.eclipse.foo:bar:1.0"));
+
+        assertTrue(result.isAccepted());
+        verify(transporter, never()).peek(any(PeekTask.class));
+    }
+
+    @Test
+    void deniedPathAbsentFromRemoteStaysDenied() throws Exception {
+        doThrow(new 
Exception("404")).when(transporter).peek(any(PeekTask.class));
+        RemoteRepositoryFilter filter = 
subject.getRemoteRepositoryFilter(session);
+        assertNotNull(filter);
+
+        // the artifact is neither covered by the prefixes file nor present on 
the remote repository:
+        // the file is consistent with reality, the filter must keep denying
+        assertFalse(filter.acceptArtifact(remoteRepository, 
jenkinsArtifact).isAccepted());
+        // and verification cost is bounded: only the first denial is checked
+        assertFalse(filter.acceptArtifact(remoteRepository, 
jenkinsArtifact).isAccepted());
+        verify(transporter, times(1)).peek(any(PeekTask.class));
+    }
+
+    @Test
+    void verificationCanBeDisabled() throws Exception {
+        
session.setConfigProperty("aether.remoteRepositoryFilter.prefixes.verifyDenied",
 "false");
+        RemoteRepositoryFilter filter = 
subject.getRemoteRepositoryFilter(session);
+        assertNotNull(filter);
+
+        assertFalse(filter.acceptArtifact(remoteRepository, 
jenkinsArtifact).isAccepted());
+        verify(transporter, never()).peek(any(PeekTask.class));
+    }
+
+    @Test
+    void offlineSessionSkipsVerification() throws Exception {
+        session.setOffline(true);
+        RemoteRepositoryFilter filter = 
subject.getRemoteRepositoryFilter(session);
+        assertNotNull(filter);
+
+        assertFalse(filter.acceptArtifact(remoteRepository, 
jenkinsArtifact).isAccepted());
+        verify(transporter, never()).peek(any(PeekTask.class));
+    }
+
+    @Test
+    void brokenPrefixesDroppedButNoInputOutcomeFalseStillDenies() throws 
Exception {
+        
session.setConfigProperty("aether.remoteRepositoryFilter.prefixes.noInputOutcome",
 "false");
+        RemoteRepositoryFilter filter = 
subject.getRemoteRepositoryFilter(session);
+        assertNotNull(filter);
+
+        // the broken file is detected and dropped (peek happened), but the 
user explicitly asked for
+        // strict filtering when no trustworthy input is available: the path 
stays denied
+        assertFalse(filter.acceptArtifact(remoteRepository, 
jenkinsArtifact).isAccepted());
+        verify(transporter, times(1)).peek(any(PeekTask.class));
+    }
+
+    @Test
+    void userProvidedPrefixesAreAuthoritative() throws Exception {
+        Path baseDir = session.getLocalRepository()
+                .getBasePath()
+                
.resolve(PrefixesRemoteRepositoryFilterSource.LOCAL_REPO_PREFIX_DIR);
+        Files.createDirectories(baseDir);
+        Files.write(baseDir.resolve("prefixes-jenkins-public.txt"), 
LEAKED_PREFIXES.getBytes(StandardCharsets.UTF_8));
+        RemoteRepositoryFilter filter = 
subject.getRemoteRepositoryFilter(session);
+        assertNotNull(filter);
+
+        // deliberately user-authored prefixes are never second-guessed
+        assertFalse(filter.acceptArtifact(remoteRepository, 
jenkinsArtifact).isAccepted());
+        verify(transporter, never()).peek(any(PeekTask.class));
+    }
+}
diff --git 
a/maven-resolver-supplier-mvn3/src/main/java/org/eclipse/aether/supplier/RepositorySystemSupplier.java
 
b/maven-resolver-supplier-mvn3/src/main/java/org/eclipse/aether/supplier/RepositorySystemSupplier.java
index 7912b864f..bff9ca9a1 100644
--- 
a/maven-resolver-supplier-mvn3/src/main/java/org/eclipse/aether/supplier/RepositorySystemSupplier.java
+++ 
b/maven-resolver-supplier-mvn3/src/main/java/org/eclipse/aether/supplier/RepositorySystemSupplier.java
@@ -586,7 +586,8 @@ public class RepositorySystemSupplier implements 
Supplier<RepositorySystem> {
                         getRepositoryKeyFunctionFactory(),
                         this::getMetadataResolver,
                         this::getRemoteRepositoryManager,
-                        getRepositoryLayoutProvider()));
+                        getRepositoryLayoutProvider(),
+                        getTransporterProvider()));
         return result;
     }
 
diff --git 
a/maven-resolver-supplier-mvn4/src/main/java/org/eclipse/aether/supplier/RepositorySystemSupplier.java
 
b/maven-resolver-supplier-mvn4/src/main/java/org/eclipse/aether/supplier/RepositorySystemSupplier.java
index 8ce257924..3c78121fe 100644
--- 
a/maven-resolver-supplier-mvn4/src/main/java/org/eclipse/aether/supplier/RepositorySystemSupplier.java
+++ 
b/maven-resolver-supplier-mvn4/src/main/java/org/eclipse/aether/supplier/RepositorySystemSupplier.java
@@ -590,7 +590,8 @@ public class RepositorySystemSupplier implements 
Supplier<RepositorySystem> {
                         getRepositoryKeyFunctionFactory(),
                         this::getMetadataResolver,
                         this::getRemoteRepositoryManager,
-                        getRepositoryLayoutProvider()));
+                        getRepositoryLayoutProvider(),
+                        getTransporterProvider()));
         return result;
     }
 
diff --git a/src/site/markdown/remote-repository-filtering.md 
b/src/site/markdown/remote-repository-filtering.md
index 7a6bf90d0..98c73b364 100644
--- a/src/site/markdown/remote-repository-filtering.md
+++ b/src/site/markdown/remote-repository-filtering.md
@@ -218,3 +218,5 @@ This leads to the following "constraints":
 Users of certain Maven Repository Managers (MRM) reported issues with 
filtering, breaking their builds. Usually the issue involves grouped/virtual 
repositories where MRM leaks random resources from member repositories, like 
the `prefixes.txt` is. Naturally, as MRM leaks one member prefixes file, and 
Maven is "tricked" into belief it got proper prefixes file from remote 
repository, builds will fail with message like **Prefix `$PREFIX` NOT allowed 
from `$SERVER_ID`** (where `$PREFIX` is so [...]
 
 In this case, user should disable prefix discovery by using 
`-Daether.remoteRepositoryFilter.prefixes.resolvePrefixFiles=false` user 
property to prevent Maven attempting to resolve prefixes file from such broken 
MRMs.
+
+Since 2.0.21, Resolver protects itself against this failure mode: when an 
auto-discovered prefixes file denies a path, the very first denial per remote 
repository is verified against the remote repository itself using a lightweight 
existence check. If the repository actually serves the denied path, the 
auto-discovered prefixes file is provably wrong; a warning naming the 
repository is emitted (report it to the repository administrator) and the file 
is ignored for the rest of the session, [...]

Reply via email to