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 41efd49cb..10c9e42fb 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
@@ -28,9 +28,9 @@
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
+import java.util.Set;
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;
@@ -207,14 +207,13 @@ 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 for that path (it denies content the
- * repository actually serves); a warning is emitted and, by default, only that verified path is allowed while
- * the prefixes file stays enforcing for all other paths (see {@link #CONFIG_PROP_VERIFY_DENIED_DROPS_TREE}
- * for the legacy behavior of dropping the whole file). 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.
+ * Configuration to verify denied paths 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 for that path (it denies content the repository actually
+ * serves); a warning is emitted and, by default, verified paths are allowed while the prefixes file stays
+ * enforcing for all other paths (see {@link #CONFIG_PROP_VERIFY_DENIED_DROPS_TREE} for the legacy behavior of
+ * dropping the whole file). If a denied path does not exist remotely, it remains denied and the negative
+ * verification result is cached for the session to prevent redundant checks.
*
* 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
@@ -364,11 +363,17 @@ private CachedPrefixes cachePrefixes(
private static final PrefixTree ENABLED_NO_INPUT = new PrefixTree("enabled-no-input");
private static final PrefixTree BROKEN = new PrefixTree("broken");
+ private enum VerificationOutcome {
+ SERVED,
+ ABSENT,
+ UNKNOWN
+ }
+
/**
* 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}), whether verification happened already, and the denied path (if any)
- * that verification proved the remote repository actually serves.
+ * {@link #CONFIG_PROP_VERIFY_DENIED}), per-path caching of paths verified as served, and per-path caching
+ * of definitively absent paths, with verification outcomes maintained independently for each path.
*/
private static final class CachedPrefixes {
private static final CachedPrefixes DISABLED_PREFIXES = new CachedPrefixes(DISABLED, false);
@@ -376,12 +381,14 @@ private static final class CachedPrefixes {
private volatile PrefixTree prefixTree;
private final boolean autoDiscovered;
- private final AtomicBoolean verifyClaimed = new AtomicBoolean(false);
- private volatile String verifiedServedPath;
+ private final Set verifiedServedPaths;
+ private final Set verifiedAbsentPaths;
private CachedPrefixes(PrefixTree prefixTree, boolean autoDiscovered) {
this.prefixTree = prefixTree;
this.autoDiscovered = autoDiscovered;
+ this.verifiedServedPaths = autoDiscovered ? ConcurrentHashMap.newKeySet() : Collections.emptySet();
+ this.verifiedAbsentPaths = autoDiscovered ? ConcurrentHashMap.newKeySet() : Collections.emptySet();
}
private PrefixTree prefixTree() {
@@ -392,21 +399,24 @@ private boolean autoDiscovered() {
return autoDiscovered;
}
- private boolean claimVerification() {
- return verifyClaimed.compareAndSet(false, true);
- }
-
private void drop() {
this.prefixTree = BROKEN;
}
private void allowVerifiedServedPath(String path) {
- this.verifiedServedPath = path;
+ this.verifiedServedPaths.add(path);
}
private boolean isVerifiedServedPath(String path) {
- String verified = this.verifiedServedPath;
- return verified != null && verified.equals(path);
+ return this.verifiedServedPaths.contains(path);
+ }
+
+ private void recordVerifiedAbsentPath(String path) {
+ this.verifiedAbsentPaths.add(path);
+ }
+
+ private boolean isVerifiedAbsentPath(String path) {
+ return this.verifiedAbsentPaths.contains(path);
}
}
@@ -554,28 +564,48 @@ private Result acceptPrefix(RemoteRepository repository, String path) {
}
boolean accepted = prefixTree.acceptedPath(path);
if (!accepted && cachedPrefixes.autoDiscovered() && isVerifyDeniedEnabled(repository)) {
- // synchronized: only the first denial is verified; concurrent denials wait for the verdict
+ if (cachedPrefixes.isVerifiedServedPath(path)) {
+ return result(
+ true,
+ NAME,
+ "Path " + path + " allowed from " + repository.getId()
+ + " (verified served despite stale auto-discovered prefixes)");
+ }
+ if (cachedPrefixes.isVerifiedAbsentPath(path)) {
+ return verifiedAbsentResult(repository, path);
+ }
+ // Fast-path checks occur before locking. The lock belongs to this repository's cached-prefix
+ // state and prevents duplicate verification of the same path during concurrent requests,
+ // intentionally preserving correctness without globally disabling filtering.
synchronized (cachedPrefixes) {
- if (cachedPrefixes.claimVerification() && remoteRepositoryServesPath(repository, path)) {
- if (isVerifyDeniedDropsTreeEnabled(repository)) {
- 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();
- } else {
- logger.warn(
- "Remote repository {} serves path {} that its auto-discovered prefixes file "
- + "denies; the prefixes file appears stale. Allowing only this verified "
- + "path; the prefixes filter stays enforcing for all other paths (set {} "
- + "to true to instead drop the whole auto-discovered prefixes file; "
- + "report this to the repository administrator)",
- repository.getId(),
- path,
- CONFIG_PROP_VERIFY_DENIED_DROPS_TREE);
- cachedPrefixes.allowVerifiedServedPath(path);
+ if (cachedPrefixes.prefixTree() == BROKEN) {
+ return noInputResult(repository, "Broken auto-discovered prefixes dropped");
+ }
+ if (!cachedPrefixes.isVerifiedServedPath(path) && !cachedPrefixes.isVerifiedAbsentPath(path)) {
+ VerificationOutcome outcome = remoteRepositoryServesPath(repository, path);
+ if (outcome == VerificationOutcome.SERVED) {
+ if (isVerifyDeniedDropsTreeEnabled(repository)) {
+ 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();
+ } else {
+ logger.warn(
+ "Remote repository {} serves path {} that its auto-discovered prefixes file "
+ + "denies; the prefixes file appears stale. Allowing only this verified "
+ + "path; the prefixes filter stays enforcing for all other paths (set {} "
+ + "to true to instead drop the whole auto-discovered prefixes file; "
+ + "report this to the repository administrator)",
+ repository.getId(),
+ path,
+ CONFIG_PROP_VERIFY_DENIED_DROPS_TREE);
+ cachedPrefixes.allowVerifiedServedPath(path);
+ }
+ } else if (outcome == VerificationOutcome.ABSENT) {
+ cachedPrefixes.recordVerifiedAbsentPath(path);
}
}
}
@@ -589,13 +619,46 @@ private Result acceptPrefix(RemoteRepository repository, String path) {
"Path " + path + " allowed from " + repository.getId()
+ " (verified served despite stale auto-discovered prefixes)");
}
+ if (cachedPrefixes.isVerifiedAbsentPath(path)) {
+ return verifiedAbsentResult(repository, path);
+ }
+ return inconclusiveVerificationResult(repository, path);
}
+ return accepted
+ ? result(true, NAME, "Path " + path + " allowed from " + repository.getId())
+ : notAllowedResult(repository, path, cachedPrefixes);
+ }
+
+ private Result verifiedAbsentResult(RemoteRepository repository, String path) {
+ return result(
+ false,
+ NAME,
+ "Path " + path + " NOT allowed from " + repository.getId()
+ + " (not listed in auto-discovered prefix file and verified absent from remote repository)");
+ }
+
+ private Result inconclusiveVerificationResult(RemoteRepository repository, String path) {
return result(
- accepted,
+ false,
NAME,
- accepted
- ? "Path " + path + " allowed from " + repository.getId()
- : "Path " + path + " NOT allowed from " + repository.getId());
+ "Path " + path + " NOT allowed from " + repository.getId()
+ + " (not listed in auto-discovered prefix file; remote verification could not confirm existence)");
+ }
+
+ private Result notAllowedResult(RemoteRepository repository, String path, CachedPrefixes cachedPrefixes) {
+ String reasoning = "Path " + path + " NOT allowed from " + repository.getId();
+ if (cachedPrefixes.autoDiscovered()) {
+ if (session.isOffline()) {
+ reasoning += " (not listed in auto-discovered prefix file; verification skipped while offline)";
+ } else {
+ reasoning += " (auto-discovered prefix file appears incomplete). If this repository serves a "
+ + "virtual aggregate with an incomplete prefix file, set -D"
+ + CONFIG_PROP_VERIFY_DENIED + "." + repository.getId()
+ + "=true or report this to the repository administrator. "
+ + "See https://maven.apache.org/resolver/remote-repository-filtering.html";
+ }
+ }
+ return result(false, NAME, reasoning);
}
private Result noInputResult(RemoteRepository repository, String reasoning) {
@@ -628,21 +691,34 @@ private boolean isVerifyDeniedDropsTreeEnabled(RemoteRepository repository) {
/**
* 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 verdict is overridden (or, with
- * {@link #CONFIG_PROP_VERIFY_DENIED_DROPS_TREE}, the whole file dropped) only when the remote repository
- * provably serves the denied path.
+ * (the transporter sits below the filtering connector, so no recursion can happen). Only definitive
+ * {@link Transporter#ERROR_NOT_FOUND} is recorded as absent so transient transport or network failures
+ * do not permanently poison the negative cache. Similarly, URI construction failure does not establish
+ * remote absence and yields {@link VerificationOutcome#UNKNOWN}.
*/
- 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;
+ private VerificationOutcome remoteRepositoryServesPath(RemoteRepository repository, String path) {
+ Transporter transporter = null;
+ try {
+ String probePath = path;
+ while (probePath.startsWith("/")) {
+ probePath = probePath.substring(1);
+ }
+ transporter = transporterProvider.newTransporter(session, repository);
+ transporter.peek(new PeekTask(new URI(null, null, probePath, null)));
+ return VerificationOutcome.SERVED;
} catch (URISyntaxException e) {
logger.debug("Cannot construct URI for denied path {} of {}", path, repository, e);
- return false;
+ return VerificationOutcome.UNKNOWN;
} catch (Exception e) {
logger.debug("Verification of denied path {} against {} failed", path, repository, e);
- return false;
+ if (transporter != null && transporter.classify(e) == Transporter.ERROR_NOT_FOUND) {
+ return VerificationOutcome.ABSENT;
+ }
+ return VerificationOutcome.UNKNOWN;
+ } finally {
+ if (transporter != null) {
+ transporter.close();
+ }
}
}
}
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
index 6c3314ead..6b5f158f4 100644
--- 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
@@ -18,6 +18,7 @@
*/
package org.eclipse.aether.internal.impl.filter;
+import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -41,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.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;
@@ -49,10 +52,12 @@
import org.junit.jupiter.api.io.TempDir;
import static org.eclipse.aether.internal.impl.checksum.Checksums.checksumsSelector;
+import static org.junit.jupiter.api.Assertions.assertEquals;
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.ArgumentMatchers.anyBoolean;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -152,6 +157,7 @@ public RepositoryPolicy getPolicy(
checksumsSelector(), new DefaultArtifactPredicateFactory(checksumsSelector()))));
// existence checks against the remote repository: peek succeeds unless a test says otherwise
transporter = mock(Transporter.class);
+ when(transporter.classify(any(Throwable.class))).thenReturn(Transporter.ERROR_NOT_FOUND);
TransporterProvider transporterProvider = mock(TransporterProvider.class);
when(transporterProvider.newTransporter(any(), any())).thenReturn(transporter);
subject = new PrefixesRemoteRepositoryFilterSource(
@@ -178,6 +184,10 @@ void deniedButServedPathIsAccepted() {
@Test
void deniedButServedPathDoesNotDisableFilterForRepository() throws Exception {
+ doThrow(new Exception("404"))
+ .when(transporter)
+ .peek(org.mockito.ArgumentMatchers.argThat(
+ task -> task.getLocation().getPath().contains("io/jenkins/other")));
RemoteRepositoryFilter filter = subject.getRemoteRepositoryFilter(session);
assertNotNull(filter);
@@ -190,8 +200,70 @@ void deniedButServedPathDoesNotDisableFilterForRepository() throws Exception {
// ...and listed paths keep being accepted, of course
assertTrue(filter.acceptArtifact(remoteRepository, new DefaultArtifact("org.eclipse.foo:bar:1.0"))
.isAccepted());
- // verification cost stays bounded: only the first denial is checked
- verify(transporter, times(1)).peek(any(PeekTask.class));
+ // both denied paths were probed independently: jenkinsArtifact (served) and io.jenkins:other (404)
+ verify(transporter, times(2)).peek(any(PeekTask.class));
+ }
+
+ @Test
+ void pomThenJarOfDeniedArtifactAreBothAccepted() throws Exception {
+ RemoteRepositoryFilter filter = subject.getRemoteRepositoryFilter(session);
+ assertNotNull(filter);
+
+ Artifact pomArtifact = new DefaultArtifact("org.jenkins-ci:version-number:pom:1.14");
+ Artifact jarArtifact = new DefaultArtifact("org.jenkins-ci:version-number:jar:1.14");
+
+ // POM verified and accepted
+ assertTrue(filter.acceptArtifact(remoteRepository, pomArtifact).isAccepted());
+ // JAR of the same artifact at a different path must also be verified and accepted
+ assertTrue(filter.acceptArtifact(remoteRepository, jarArtifact).isAccepted());
+
+ // both distinct paths were probed against remote
+ verify(transporter, times(2)).peek(any(PeekTask.class));
+ }
+
+ @Test
+ void multipleDeniedArtifactsAreIndependentlyVerifiedAndAccepted() throws Exception {
+ RemoteRepositoryFilter filter = subject.getRemoteRepositoryFilter(session);
+ assertNotNull(filter);
+
+ Artifact libA = new DefaultArtifact("com.corp:lib-a:1.0");
+ Artifact libB = new DefaultArtifact("com.corp:lib-b:1.0");
+
+ assertTrue(filter.acceptArtifact(remoteRepository, libA).isAccepted());
+ assertTrue(filter.acceptArtifact(remoteRepository, libB).isAccepted());
+
+ // each denied path is independently probed and accepted
+ verify(transporter, times(2)).peek(any(PeekTask.class));
+ }
+
+ @Test
+ void unservedDeniedPathRemainsBlockedAndDoesNotDisableFilter() throws Exception {
+ Artifact servedArtifact = jenkinsArtifact;
+ Artifact unservedArtifact = new DefaultArtifact("com.corp:unserved-lib:1.0");
+ Artifact prefixListedArtifact = new DefaultArtifact("org.eclipse.foo:bar:1.0");
+
+ doThrow(new Exception("404"))
+ .when(transporter)
+ .peek(org.mockito.ArgumentMatchers.argThat(
+ task -> task.getLocation().getPath().contains("com/corp/unserved-lib")));
+
+ RemoteRepositoryFilter filter = subject.getRemoteRepositoryFilter(session);
+ assertNotNull(filter);
+
+ // served artifact is accepted after probe
+ assertTrue(filter.acceptArtifact(remoteRepository, servedArtifact).isAccepted());
+
+ // unserved artifact is rejected
+ assertFalse(filter.acceptArtifact(remoteRepository, unservedArtifact).isAccepted());
+
+ // repeated request for unserved artifact remains rejected and does not probe again (negative cache)
+ assertFalse(filter.acceptArtifact(remoteRepository, unservedArtifact).isAccepted());
+
+ // prefix-listed artifact remains accepted without remote probe
+ assertTrue(filter.acceptArtifact(remoteRepository, prefixListedArtifact).isAccepted());
+
+ // total probes: 1 for servedArtifact + 1 for unservedArtifact (second unserved request was cached)
+ verify(transporter, times(2)).peek(any(PeekTask.class));
}
@Test
@@ -239,9 +311,144 @@ void deniedPathAbsentFromRemoteStaysDenied() throws Exception {
// 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());
+ RemoteRepositoryFilter.Result result1 = filter.acceptArtifact(remoteRepository, jenkinsArtifact);
+ assertFalse(result1.isAccepted());
+ assertTrue(result1.reasoning().contains("verified absent from remote repository"));
+ assertFalse(result1.reasoning().contains("-Daether.remoteRepositoryFilter.prefixes.verifyDenied"));
+ assertFalse(result1.reasoning().contains("appears incomplete"));
+
// and verification cost is bounded: only the first denial is checked
- assertFalse(filter.acceptArtifact(remoteRepository, jenkinsArtifact).isAccepted());
+ RemoteRepositoryFilter.Result result2 = filter.acceptArtifact(remoteRepository, jenkinsArtifact);
+ assertFalse(result2.isAccepted());
+ assertTrue(result2.reasoning().contains("verified absent from remote repository"));
+ assertFalse(result2.reasoning().contains("-Daether.remoteRepositoryFilter.prefixes.verifyDenied"));
+ assertFalse(result2.reasoning().contains("appears incomplete"));
+ verify(transporter, times(1)).peek(any(PeekTask.class));
+ }
+
+ @Test
+ void deniedAutoDiscoveredPathExplainsRepositorySpecificWorkaround() {
+ session.setConfigProperty("aether.remoteRepositoryFilter.prefixes.verifyDenied", "false");
+ RemoteRepositoryFilter filter = subject.getRemoteRepositoryFilter(session);
+ assertNotNull(filter);
+
+ RemoteRepositoryFilter.Result result = filter.acceptArtifact(remoteRepository, jenkinsArtifact);
+
+ assertFalse(result.isAccepted());
+ assertTrue(result.reasoning()
+ .contains("-Daether.remoteRepositoryFilter.prefixes.verifyDenied.jenkins-public=true"));
+ assertTrue(result.reasoning().contains("auto-discovered prefix file appears incomplete"));
+ assertTrue(result.reasoning().contains("report this to the repository administrator"));
+ assertTrue(result.reasoning().contains("https://maven.apache.org/resolver/remote-repository-filtering.html"));
+ }
+
+ @Test
+ void transportFailureDoesNotNegativeCachePath() throws Exception {
+ Exception transportError = new Exception("Connection timed out");
+ doThrow(transportError).when(transporter).peek(any(PeekTask.class));
+ when(transporter.classify(transportError)).thenReturn(Transporter.ERROR_OTHER);
+
+ RemoteRepositoryFilter filter = subject.getRemoteRepositoryFilter(session);
+ assertNotNull(filter);
+
+ // first attempt fails due to transport error: artifact is not accepted
+ RemoteRepositoryFilter.Result result1 = filter.acceptArtifact(remoteRepository, jenkinsArtifact);
+ assertFalse(result1.isAccepted());
+ assertTrue(result1.reasoning().contains("remote verification could not confirm existence"));
+ assertFalse(result1.reasoning().contains("-Daether.remoteRepositoryFilter.prefixes.verifyDenied"));
+ assertFalse(result1.reasoning().contains("verified absent"));
+
+ // second attempt must retry (not permanently negative-cached)
+ RemoteRepositoryFilter.Result result2 = filter.acceptArtifact(remoteRepository, jenkinsArtifact);
+ assertFalse(result2.isAccepted());
+ assertTrue(result2.reasoning().contains("remote verification could not confirm existence"));
+ verify(transporter, times(2)).peek(any(PeekTask.class));
+ }
+
+ @Test
+ void uriConstructionFailureDoesNotNegativeCachePath() throws Exception {
+ RepositoryLayout customLayout = mock(RepositoryLayout.class);
+ when(customLayout.getLocation(any(Artifact.class), anyBoolean()))
+ .thenReturn(new URI("http", "example.com", "/:invalid", null));
+
+ RepositoryLayoutProvider customLayoutProvider = mock(RepositoryLayoutProvider.class);
+ when(customLayoutProvider.newRepositoryLayout(any(), any())).thenReturn(customLayout);
+
+ MetadataResolver metadataResolver = mock(MetadataResolver.class);
+ Path leakedPrefixesFile = tempDir.resolve("prefixes-jenkins-public.txt");
+ when(metadataResolver.resolveMetadata(any(RepositorySystemSession.class), any(Collection.class)))
+ .thenAnswer(invocation -> {
+ Collection 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 = mock(RemoteRepositoryManager.class);
+ TransporterProvider transporterProvider = mock(TransporterProvider.class);
+ when(transporterProvider.newTransporter(any(), any())).thenReturn(transporter);
+
+ PrefixesRemoteRepositoryFilterSource customSubject = new PrefixesRemoteRepositoryFilterSource(
+ new DefaultRepositoryKeyFunctionFactory(),
+ () -> metadataResolver,
+ () -> remoteRepositoryManager,
+ customLayoutProvider,
+ transporterProvider);
+
+ RemoteRepositoryFilter filter = customSubject.getRemoteRepositoryFilter(session);
+ assertNotNull(filter);
+
+ // first attempt fails URI construction (relative URI cannot have colon in first segment)
+ RemoteRepositoryFilter.Result result1 = filter.acceptArtifact(remoteRepository, jenkinsArtifact);
+ assertFalse(result1.isAccepted());
+ assertTrue(result1.reasoning().contains("remote verification could not confirm existence"));
+ assertFalse(result1.reasoning().contains("verified absent"));
+ assertFalse(result1.reasoning().contains("-Daether.remoteRepositoryFilter.prefixes.verifyDenied"));
+
+ // second attempt must still not be cached as absent (outcome was UNKNOWN)
+ RemoteRepositoryFilter.Result result2 = filter.acceptArtifact(remoteRepository, jenkinsArtifact);
+ assertFalse(result2.isAccepted());
+ assertTrue(result2.reasoning().contains("remote verification could not confirm existence"));
+ assertFalse(result2.reasoning().contains("verified absent"));
+ verify(transporter, never()).peek(any(PeekTask.class));
+ }
+
+ @Test
+ void deniedPathWithLeadingSlashIsVerifiedAndAccepted() throws Exception {
+ RepositoryLayout customLayout = mock(RepositoryLayout.class);
+ when(customLayout.getLocation(any(Artifact.class), anyBoolean()))
+ .thenReturn(new URI(
+ "http", "example.com", "/org/jenkins-ci/version-number/1.14/version-number-1.14.jar", null));
+
+ RepositoryLayoutProvider customLayoutProvider = mock(RepositoryLayoutProvider.class);
+ when(customLayoutProvider.newRepositoryLayout(any(), any())).thenReturn(customLayout);
+
+ MetadataResolver metadataResolver = mock(MetadataResolver.class);
+ Path leakedPrefixesFile = tempDir.resolve("prefixes-jenkins-public.txt");
+ when(metadataResolver.resolveMetadata(any(RepositorySystemSession.class), any(Collection.class)))
+ .thenAnswer(invocation -> {
+ Collection 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 = mock(RemoteRepositoryManager.class);
+ TransporterProvider transporterProvider = mock(TransporterProvider.class);
+ when(transporterProvider.newTransporter(any(), any())).thenReturn(transporter);
+
+ PrefixesRemoteRepositoryFilterSource customSubject = new PrefixesRemoteRepositoryFilterSource(
+ new DefaultRepositoryKeyFunctionFactory(),
+ () -> metadataResolver,
+ () -> remoteRepositoryManager,
+ customLayoutProvider,
+ transporterProvider);
+
+ RemoteRepositoryFilter filter = customSubject.getRemoteRepositoryFilter(session);
+ assertNotNull(filter);
+
+ RemoteRepositoryFilter.Result result = filter.acceptArtifact(remoteRepository, jenkinsArtifact);
+ assertTrue(result.isAccepted());
verify(transporter, times(1)).peek(any(PeekTask.class));
}
@@ -261,7 +468,10 @@ void offlineSessionSkipsVerification() throws Exception {
RemoteRepositoryFilter filter = subject.getRemoteRepositoryFilter(session);
assertNotNull(filter);
- assertFalse(filter.acceptArtifact(remoteRepository, jenkinsArtifact).isAccepted());
+ RemoteRepositoryFilter.Result result = filter.acceptArtifact(remoteRepository, jenkinsArtifact);
+ assertFalse(result.isAccepted());
+ assertTrue(result.reasoning().contains("verification skipped while offline"));
+ assertFalse(result.reasoning().contains("-Daether.remoteRepositoryFilter.prefixes.verifyDenied"));
verify(transporter, never()).peek(any(PeekTask.class));
}
@@ -289,7 +499,14 @@ void userProvidedPrefixesAreAuthoritative() throws Exception {
assertNotNull(filter);
// deliberately user-authored prefixes are never second-guessed
- assertFalse(filter.acceptArtifact(remoteRepository, jenkinsArtifact).isAccepted());
+ RemoteRepositoryFilter.Result result = filter.acceptArtifact(remoteRepository, jenkinsArtifact);
+ assertFalse(result.isAccepted());
+ assertEquals(
+ "prefixes: Path org/jenkins-ci/version-number/1.14/version-number-1.14.jar NOT allowed from jenkins-public",
+ result.reasoning());
+ assertFalse(result.reasoning().contains("verifyDenied"));
+ assertFalse(result.reasoning().contains("auto-discovered"));
+ assertFalse(result.reasoning().contains("https://maven.apache.org/resolver/remote-repository-filtering.html"));
verify(transporter, never()).peek(any(PeekTask.class));
}
}