From 123d3f83626350cd32930e3c221da88c85597906 Mon Sep 17 00:00:00 2001 From: Tim te Beek Date: Tue, 21 Apr 2026 13:54:55 +0200 Subject: [PATCH 1/2] Fall back to anonymous download on 4xx with Maven credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Maven settings.xml credentials are rejected by the remote repository (401/403), retry the JAR download without authentication. Mirrors `MavenPomDownloader.requestAsAuthenticatedOrAnonymous()` and Apache Maven's behavior, so anonymous-accessible artifacts resolve even when configured credentials are invalid. Also fixes two nits observed during troubleshooting: - local cache filename was missing the hyphen before the classifier (`foo-1.0.0recipes.jar` → `foo-1.0.0-recipes.jar`) - download error message omitted the classifier --- .../maven/cache/LocalMavenArtifactCache.java | 2 +- .../utilities/MavenArtifactDownloader.java | 59 +++++++++++++++---- .../MavenArtifactDownloaderTest.java | 57 ++++++++++++++++++ 3 files changed, 104 insertions(+), 14 deletions(-) diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/cache/LocalMavenArtifactCache.java b/rewrite-maven/src/main/java/org/openrewrite/maven/cache/LocalMavenArtifactCache.java index 5777978f2d3..b8b34a50690 100644 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/cache/LocalMavenArtifactCache.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/cache/LocalMavenArtifactCache.java @@ -85,7 +85,7 @@ private Path dependencyPath(ResolvedDependency dependency) { return resolvedPath.resolve(dependency.getArtifactId() + "-" + (dependency.getDatedSnapshotVersion() == null ? dependency.getVersion() : dependency.getDatedSnapshotVersion()) + - (dependency.getRequested().getClassifier() == null ? "" : dependency.getRequested().getClassifier()) + + (dependency.getRequested().getClassifier() == null ? "" : "-" + dependency.getRequested().getClassifier()) + ".jar"); } } diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/utilities/MavenArtifactDownloader.java b/rewrite-maven/src/main/java/org/openrewrite/maven/utilities/MavenArtifactDownloader.java index b675c991d7c..6659fb10e82 100644 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/utilities/MavenArtifactDownloader.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/utilities/MavenArtifactDownloader.java @@ -110,15 +110,35 @@ public MavenArtifactDownloader(MavenArtifactCache mavenArtifactCache, bodyStream = Files.newInputStream(Paths.get(URI.create(uri))); } else { HttpSender.Request.Builder request = applyAuthentication(dependency.getRepository(), httpSender.get(uri)); - try (HttpSender.Response response = Failsafe.with(retryPolicy).get(() -> httpSender.send(request.build())); - InputStream body = response.getBody()) { - if (!response.isSuccessful() || body == null) { - onError.accept(new MavenDownloadingException(String.format("Unable to download dependency %s:%s:%s from %s. Response was %d", - dependency.getGroupId(), dependency.getArtifactId(), dependency.getVersion(), uri, response.getCode()), null, + try { + byte[] responseBytes = null; + int responseCode; + try (HttpSender.Response response = Failsafe.with(retryPolicy).get(() -> httpSender.send(request.build())); + InputStream body = response.getBody()) { + responseCode = response.getCode(); + if (response.isSuccessful() && body != null) { + responseBytes = readAllBytes(body); + } + } + // Fall back to anonymous if authenticated request fails with a 4xx client error + if (responseBytes == null && hasCredentials(dependency.getRepository()) && responseCode >= 400 && responseCode < 500) { + try (HttpSender.Response response = Failsafe.with(retryPolicy).get(() -> httpSender.send(httpSender.get(uri).build())); + InputStream body = response.getBody()) { + responseCode = response.getCode(); + if (response.isSuccessful() && body != null) { + responseBytes = readAllBytes(body); + } + } + } + if (responseBytes == null) { + onError.accept(new MavenDownloadingException(String.format("Unable to download dependency %s:%s:%s%s from %s. Response was %d", + dependency.getGroupId(), dependency.getArtifactId(), dependency.getVersion(), + dependency.getClassifier() == null ? "" : ":" + dependency.getClassifier(), + uri, responseCode), null, dependency.getRequested().getGav())); return null; } - bodyStream = new ByteArrayInputStream(readAllBytes(body)); + bodyStream = new ByteArrayInputStream(responseBytes); } catch (Throwable t) { Throwable cause = t instanceof FailsafeException && t.getCause() != null ? t.getCause() : t; throw new MavenDownloadingException("Unable to download dependency", cause, @@ -130,14 +150,27 @@ public MavenArtifactDownloader(MavenArtifactCache mavenArtifactCache, } private HttpSender.Request.Builder applyAuthentication(MavenRepository repository, HttpSender.Request.Builder request) { + MavenSettings.Server authInfo = serverIdToServer.get(repository.getId()); + if (authInfo != null && authInfo.getConfiguration() != null && authInfo.getConfiguration().getHttpHeaders() != null) { + for (MavenSettings.HttpHeader header : authInfo.getConfiguration().getHttpHeaders()) { + request.withHeader(header.getName(), header.getValue()); + } + } + String[] credentials = resolveCredentials(repository); + if (credentials != null) { + return request.withBasicAuthentication(credentials[0], credentials[1]); + } + return request; + } + + private boolean hasCredentials(MavenRepository repository) { + return resolveCredentials(repository) != null; + } + + private String @Nullable [] resolveCredentials(MavenRepository repository) { String username, password; MavenSettings.Server authInfo = serverIdToServer.get(repository.getId()); if (authInfo != null) { - if (authInfo.getConfiguration() != null && authInfo.getConfiguration().getHttpHeaders() != null) { - for (MavenSettings.HttpHeader header : authInfo.getConfiguration().getHttpHeaders()) { - request.withHeader(header.getName(), header.getValue()); - } - } username = authInfo.getUsername(); password = authInfo.getPassword(); } else { @@ -146,8 +179,8 @@ private HttpSender.Request.Builder applyAuthentication(MavenRepository repositor } if (username != null && !username.contains("${") && password != null && !password.contains("${")) { - return request.withBasicAuthentication(username, password); + return new String[]{username, password}; } - return request; + return null; } } diff --git a/rewrite-maven/src/test/java/org/openrewrite/maven/utilities/MavenArtifactDownloaderTest.java b/rewrite-maven/src/test/java/org/openrewrite/maven/utilities/MavenArtifactDownloaderTest.java index a501181570f..0a553dc814f 100644 --- a/rewrite-maven/src/test/java/org/openrewrite/maven/utilities/MavenArtifactDownloaderTest.java +++ b/rewrite-maven/src/test/java/org/openrewrite/maven/utilities/MavenArtifactDownloaderTest.java @@ -132,6 +132,63 @@ void downloadDependenciesWithClassifier(@TempDir Path tempDir) { } } + @Test + void fallsBackToAnonymousWhenServerReturns401(@TempDir Path tempDir) throws Exception { + byte[] jarBytes = {0x50, 0x4B, 0x03, 0x04}; + + try (MockWebServer mockRepo = new MockWebServer()) { + mockRepo.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + if (request.getHeader("Authorization") != null) { + return new MockResponse().setResponseCode(401); + } + return new MockResponse().setResponseCode(200) + .setBody(new okio.Buffer().write(jarBytes)); + } + }); + mockRepo.start(); + + String repoUrl = "http://" + mockRepo.getHostName() + ":" + mockRepo.getPort(); + MavenSettings settings = MavenSettings.parse(new Parser.Input( + Path.of("settings.xml"), () -> new ByteArrayInputStream( + //language=xml + """ + + + + mock-repo + bad-user + bad-password + + + + """.getBytes())), new InMemoryExecutionContext()); + + MavenArtifactCache artifactCache = new LocalMavenArtifactCache(tempDir); + AtomicReference error = new AtomicReference<>(); + MavenArtifactDownloader downloader = new MavenArtifactDownloader( + artifactCache, settings, error::set); + + MavenRepository repo = new MavenRepository( + "mock-repo", repoUrl, "true", "false", true, null, null, null, false); + GroupArtifactVersion gav = new GroupArtifactVersion("com.example", "test-lib", "1.0.0"); + ResolvedDependency dep = ResolvedDependency.builder() + .repository(repo) + .gav(new ResolvedGroupArtifactVersion(repoUrl, gav.getGroupId(), gav.getArtifactId(), gav.getVersion(), null)) + .requested(Dependency.builder().gav(gav).build()) + .build(); + + Path artifact = downloader.downloadArtifact(dep); + + assertThat(artifact).isNotNull(); + assertThat(error.get()).isNull(); + assertThat(mockRepo.getRequestCount()).isEqualTo(2); + assertThat(mockRepo.takeRequest().getHeader("Authorization")).isNotNull(); + assertThat(mockRepo.takeRequest().getHeader("Authorization")).isNull(); + } + } + @Test void fallsBackToAnonymousWhenCredentialsRejected(@TempDir Path tempDir) throws Exception { byte[] jarBytes = {0x50, 0x4B, 0x03, 0x04}; // minimal ZIP magic bytes From 041e420957a155cc8aea27e12028fea785878e50 Mon Sep 17 00:00:00 2001 From: Tim te Beek Date: Thu, 2 Jul 2026 14:39:34 +0200 Subject: [PATCH 2/2] Skip anonymous fallback on transient client errors (408/425/429) Only retry a rejected authenticated JAR download anonymously when the response is a genuine credential rejection, not a transient client error (408 timeout, 425 too early, 429 too many requests). Mirrors MavenPomDownloader.HttpSenderResponseException#isClientSideException(). Also treats configured HTTP-header auth as authentication when deciding whether an anonymous retry is worthwhile. --- .../utilities/MavenArtifactDownloader.java | 37 ++++-- .../MavenArtifactDownloaderTest.java | 114 ++++++++++++++++++ 2 files changed, 142 insertions(+), 9 deletions(-) diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/utilities/MavenArtifactDownloader.java b/rewrite-maven/src/main/java/org/openrewrite/maven/utilities/MavenArtifactDownloader.java index 6659fb10e82..4ac58ff038a 100644 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/utilities/MavenArtifactDownloader.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/utilities/MavenArtifactDownloader.java @@ -37,11 +37,13 @@ import java.nio.file.Paths; import java.time.Duration; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.TimeoutException; import java.util.function.Consumer; import java.util.function.Function; +import static java.util.Collections.emptyList; import static java.util.Objects.requireNonNull; import static java.util.stream.Collectors.toMap; import static org.openrewrite.internal.StreamUtils.readAllBytes; @@ -120,8 +122,8 @@ public MavenArtifactDownloader(MavenArtifactCache mavenArtifactCache, responseBytes = readAllBytes(body); } } - // Fall back to anonymous if authenticated request fails with a 4xx client error - if (responseBytes == null && hasCredentials(dependency.getRepository()) && responseCode >= 400 && responseCode < 500) { + // Fall back to anonymous if authenticated request fails with a client error + if (responseBytes == null && isClientSideError(responseCode) && hasAuthentication(dependency.getRepository())) { try (HttpSender.Response response = Failsafe.with(retryPolicy).get(() -> httpSender.send(httpSender.get(uri).build())); InputStream body = response.getBody()) { responseCode = response.getCode(); @@ -150,11 +152,8 @@ public MavenArtifactDownloader(MavenArtifactCache mavenArtifactCache, } private HttpSender.Request.Builder applyAuthentication(MavenRepository repository, HttpSender.Request.Builder request) { - MavenSettings.Server authInfo = serverIdToServer.get(repository.getId()); - if (authInfo != null && authInfo.getConfiguration() != null && authInfo.getConfiguration().getHttpHeaders() != null) { - for (MavenSettings.HttpHeader header : authInfo.getConfiguration().getHttpHeaders()) { - request.withHeader(header.getName(), header.getValue()); - } + for (MavenSettings.HttpHeader header : resolveHttpHeaders(repository)) { + request.withHeader(header.getName(), header.getValue()); } String[] credentials = resolveCredentials(repository); if (credentials != null) { @@ -163,8 +162,28 @@ private HttpSender.Request.Builder applyAuthentication(MavenRepository repositor return request; } - private boolean hasCredentials(MavenRepository repository) { - return resolveCredentials(repository) != null; + private boolean hasAuthentication(MavenRepository repository) { + return !resolveHttpHeaders(repository).isEmpty() || resolveCredentials(repository) != null; + } + + /** + * All 400s are client-side errors, but 408 (timeout), 425 (too early) and 429 (too many requests) are transient + * rather than credential rejections, so an anonymous retry is pointless for them. Mirrors + * {@code MavenPomDownloader.HttpSenderResponseException#isClientSideException()}. + */ + private static boolean isClientSideError(int responseCode) { + if (responseCode < 400 || responseCode > 499) { + return false; + } + return responseCode != 408 && responseCode != 425 && responseCode != 429; + } + + private List resolveHttpHeaders(MavenRepository repository) { + MavenSettings.Server authInfo = serverIdToServer.get(repository.getId()); + if (authInfo != null && authInfo.getConfiguration() != null && authInfo.getConfiguration().getHttpHeaders() != null) { + return authInfo.getConfiguration().getHttpHeaders(); + } + return emptyList(); } private String @Nullable [] resolveCredentials(MavenRepository repository) { diff --git a/rewrite-maven/src/test/java/org/openrewrite/maven/utilities/MavenArtifactDownloaderTest.java b/rewrite-maven/src/test/java/org/openrewrite/maven/utilities/MavenArtifactDownloaderTest.java index 0a553dc814f..127c39740e3 100644 --- a/rewrite-maven/src/test/java/org/openrewrite/maven/utilities/MavenArtifactDownloaderTest.java +++ b/rewrite-maven/src/test/java/org/openrewrite/maven/utilities/MavenArtifactDownloaderTest.java @@ -189,6 +189,120 @@ public MockResponse dispatch(RecordedRequest request) { } } + @Test + void fallsBackToAnonymousWhenHttpHeaderAuthRejected(@TempDir Path tempDir) throws Exception { + byte[] jarBytes = {0x50, 0x4B, 0x03, 0x04}; + + try (MockWebServer mockRepo = new MockWebServer()) { + mockRepo.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + if (request.getHeader("X-Auth-Token") != null) { + return new MockResponse().setResponseCode(401); + } + return new MockResponse().setResponseCode(200) + .setBody(new okio.Buffer().write(jarBytes)); + } + }); + mockRepo.start(); + + String repoUrl = "http://" + mockRepo.getHostName() + ":" + mockRepo.getPort(); + MavenSettings settings = MavenSettings.parse(new Parser.Input( + Path.of("settings.xml"), () -> new ByteArrayInputStream( + //language=xml + """ + + + + mock-repo + + + + X-Auth-Token + bad-token + + + + + + + """.getBytes())), new InMemoryExecutionContext()); + + MavenArtifactCache artifactCache = new LocalMavenArtifactCache(tempDir); + AtomicReference error = new AtomicReference<>(); + MavenArtifactDownloader downloader = new MavenArtifactDownloader( + artifactCache, settings, error::set); + + MavenRepository repo = new MavenRepository( + "mock-repo", repoUrl, "true", "false", true, null, null, null, false); + GroupArtifactVersion gav = new GroupArtifactVersion("com.example", "test-lib", "1.0.0"); + ResolvedDependency dep = ResolvedDependency.builder() + .repository(repo) + .gav(new ResolvedGroupArtifactVersion(repoUrl, gav.getGroupId(), gav.getArtifactId(), gav.getVersion(), null)) + .requested(Dependency.builder().gav(gav).build()) + .build(); + + Path artifact = downloader.downloadArtifact(dep); + + assertThat(artifact).isNotNull(); + assertThat(error.get()).isNull(); + assertThat(mockRepo.getRequestCount()).isEqualTo(2); + assertThat(mockRepo.takeRequest().getHeader("X-Auth-Token")).isNotNull(); + assertThat(mockRepo.takeRequest().getHeader("X-Auth-Token")).isNull(); + } + } + + @Test + void doesNotFallBackToAnonymousOnTransientClientError(@TempDir Path tempDir) throws Exception { + try (MockWebServer mockRepo = new MockWebServer()) { + mockRepo.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + // 429 is a transient rate-limit, not a credential rejection, so no anonymous retry should follow + return new MockResponse().setResponseCode(429); + } + }); + mockRepo.start(); + + String repoUrl = "http://" + mockRepo.getHostName() + ":" + mockRepo.getPort(); + MavenSettings settings = MavenSettings.parse(new Parser.Input( + Path.of("settings.xml"), () -> new ByteArrayInputStream( + //language=xml + """ + + + + mock-repo + bad-user + bad-password + + + + """.getBytes())), new InMemoryExecutionContext()); + + MavenArtifactCache artifactCache = new LocalMavenArtifactCache(tempDir); + AtomicReference error = new AtomicReference<>(); + MavenArtifactDownloader downloader = new MavenArtifactDownloader( + artifactCache, settings, error::set); + + MavenRepository repo = new MavenRepository( + "mock-repo", repoUrl, "true", "false", true, null, null, null, false); + GroupArtifactVersion gav = new GroupArtifactVersion("com.example", "test-lib", "1.0.0"); + ResolvedDependency dep = ResolvedDependency.builder() + .repository(repo) + .gav(new ResolvedGroupArtifactVersion(repoUrl, gav.getGroupId(), gav.getArtifactId(), gav.getVersion(), null)) + .requested(Dependency.builder().gav(gav).build()) + .build(); + + Path artifact = downloader.downloadArtifact(dep); + + assertThat(artifact).isNull(); + assertThat(error.get()).isNotNull(); + assertThat(mockRepo.getRequestCount()).isEqualTo(1); + assertThat(mockRepo.takeRequest().getHeader("Authorization")).isNotNull(); + } + } + @Test void fallsBackToAnonymousWhenCredentialsRejected(@TempDir Path tempDir) throws Exception { byte[] jarBytes = {0x50, 0x4B, 0x03, 0x04}; // minimal ZIP magic bytes