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)); } }