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, [...]