From e7dcd6accbce98542ac9ff4e752ee735f9c479e7 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 31 Aug 2026 13:46:24 +0200 Subject: [PATCH 01/18] =?UTF-8?q?fix:=20f001=20(MEDIUM)=20=E2=80=94=20Keep?= =?UTF-8?q?s=20v1's=20structure:=20AbstractDeployMojo.validateCredentialBi?= =?UTF-8?q?nding=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security audit finding f001 (MEDIUM). Keeps v1's structure: AbstractDeployMojo.validateCredentialBinding guard, known-URL collection (settings mirrors + profile repositories/pluginRepositories, plus distributionManagement via DeployMojo's getKnownRepositoryUrls override), trailing-slash URL normalization, and the user-property-only -Dmaven.deploy.allowCredentialReuse=true knob (session.getUserProperties() only, not POM-settable). v2 adds provenance semantics (fleet precedent: checkstyle bug_04 / clean bug_08): validateCredentialBinding(id, url, fromUserProperty). Mismatch against non-empty known URLs is refused regardless of provenance (unchanged from v1). Empty-record case no longer passes silently: POM-sourced (DeployMojo computes isFromUserProperty by requiring the -D user property to be present AND equal to the value in use, since explicit POM beats -D in Maven precedence; the alt selection chain records provenance per parameter name) -> REFUSE naming id and URL, knob-overridable; CLI-sourced -> WARN (id has stored credentials, no known URL for it, credentials will be sent to ). DeployFileMojo keeps the 2-arg call, which delegates with fromUserProperty=true (deploy-file is CLI-driven by nature). Javadoc on getKnownRepositoryUrls documents dm-sourced known URLs as advisory in the malicious-POM model; parameter javadoc for altDeploymentRepository and repositoryId documents the provenance-dependent behavior. Co-Authored-By: Claude Opus 4.6 --- .../plugins/deploy/AbstractDeployMojo.java | 173 ++++++++++++++++++ .../maven/plugins/deploy/DeployFileMojo.java | 11 ++ .../maven/plugins/deploy/DeployMojo.java | 80 +++++++- .../maven/plugins/deploy/DeployMojoTest.java | 123 +++++++++++++ 4 files changed, 385 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/apache/maven/plugins/deploy/AbstractDeployMojo.java b/src/main/java/org/apache/maven/plugins/deploy/AbstractDeployMojo.java index 7612a690..617f8810 100644 --- a/src/main/java/org/apache/maven/plugins/deploy/AbstractDeployMojo.java +++ b/src/main/java/org/apache/maven/plugins/deploy/AbstractDeployMojo.java @@ -18,6 +18,10 @@ */ package org.apache.maven.plugins.deploy; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.Map; + import org.apache.maven.api.RemoteRepository; import org.apache.maven.api.Session; import org.apache.maven.api.Version; @@ -26,6 +30,11 @@ import org.apache.maven.api.plugin.Mojo; import org.apache.maven.api.plugin.MojoException; import org.apache.maven.api.plugin.annotations.Parameter; +import org.apache.maven.api.settings.Mirror; +import org.apache.maven.api.settings.Profile; +import org.apache.maven.api.settings.Repository; +import org.apache.maven.api.settings.Server; +import org.apache.maven.api.settings.Settings; /** * Abstract class for Deploy mojo's. @@ -35,6 +44,13 @@ public abstract class AbstractDeployMojo implements Mojo { private static final String FIXED_MAVEN_VERSION = "3.9.0"; + /** + * User property (not settable from the POM, only via {@code -D} on the command line or in + * {@code MAVEN_OPTS}/{@code .mvn/maven.config}) that disables the repository id→URL + * credential-binding check performed before a credentials-bearing deployment repository is used. + */ + static final String ALLOW_CREDENTIAL_REUSE_PROPERTY = "maven.deploy.allowCredentialReuse"; + @Inject protected Log logger; @@ -92,6 +108,163 @@ protected RemoteRepository createDeploymentArtifactRepository(String id, String return getSession().createRemoteRepository(id, url); } + /** + * Guards the repository id→URL credential binding: Maven resolves the credentials for a + * deployment repository purely by matching its id against a {@code } entry in + * {@code settings.xml}, so any component that pairs a known server id with a new URL + * re-targets those credentials. Equivalent to + * {@link #validateCredentialBinding(String, String, boolean) validateCredentialBinding(id, url, true)}: + * this overload is for values that are command-line-supplied by nature (deploy-file's + * {@code repositoryId}/{@code url} are plain {@code -D} parameters typed by the operator), so + * the no-URL-on-record case warns instead of refusing. + * + * @param id the repository id the deployment would bind credentials for + * @param url the URL the deployment would send those credentials to + * @throws MojoException when the binding re-targets known credentials to an unknown URL + */ + protected void validateCredentialBinding(String id, String url) throws MojoException { + validateCredentialBinding(id, url, true); + } + + /** + * Guards the repository id→URL credential binding, weighing the provenance of the + * binding. When the given id matches a {@code settings.xml} server entry (so credentials are at + * stake), two cases are distinguished: + *
    + *
  • URLs on record for the id (mirrors, profile repositories, and — for the + * deploy goal — the project's {@code distributionManagement}): the given URL must match + * one of them; otherwise the deployment is refused, naming both URLs — regardless of + * provenance.
  • + *
  • No URL on record for the id: the binding cannot be cross-checked, which is the + * mainline redirection shape (credentials stored for an id such as {@code ossrh} that + * settings.xml binds to no URL). A value the operator typed on the command line + * ({@code fromUserProperty}) proceeds with a WARN naming the URL the credentials will be sent + * to; a POM-sourced value (pom property or plugin configuration — attacker-writable in + * the malicious-POM model) is refused.
  • + *
+ * Every refusal can be overridden with {@code -D}{@value #ALLOW_CREDENTIAL_REUSE_PROPERTY}{@code =true} + * (a user property: it cannot be set from a POM). + * + * @param id the repository id the deployment would bind credentials for + * @param url the URL the deployment would send those credentials to + * @param fromUserProperty whether the id/url pair was supplied on the command line + * ({@code -D} session user property) rather than from the POM or plugin configuration + * @throws MojoException when the binding re-targets known credentials to an unknown URL, or when + * a POM-sourced binding pairs stored credentials with a URL this build knows nothing about + */ + protected void validateCredentialBinding(String id, String url, boolean fromUserProperty) throws MojoException { + if (id == null || id.isEmpty() || url == null || url.isEmpty()) { + return; + } + Settings settings = session.getSettings(); + if (settings == null) { + return; + } + boolean idHasCredentials = false; + for (Server server : settings.getServers()) { + if (id.equals(server.getId())) { + idHasCredentials = true; + break; + } + } + if (!idHasCredentials) { + return; + } + Collection knownUrls = getKnownRepositoryUrls(id); + if (knownUrls.isEmpty()) { + // The server id carries credentials but no URL is on record for it in this build. + // This must not pass silently: it is the mainline redirection shape. Provenance decides: + // an operator-typed (-D) value proceeds with a warning, a POM-sourced value is refused. + if (fromUserProperty) { + getLog().warn("Repository id '" + id + "' has credentials stored in settings.xml but no URL is on" + + " record for that id in this build; those credentials will be sent to " + url); + return; + } + if (isCredentialReuseAllowed()) { + getLog().warn("Repository id '" + id + "' has credentials stored in settings.xml but no URL is on" + + " record for that id in this build, and the repository was configured from the POM;" + + " sending those credentials to " + url + " because -D" + + ALLOW_CREDENTIAL_REUSE_PROPERTY + "=true is set"); + return; + } + throw new MojoException( + "Refusing to deploy: repository id '" + id + "' matches a settings.xml server entry (stored" + + " credentials), no URL is on record for that id in this build, and the alternative" + + " repository was configured from the POM rather than the command line. The deployment" + + " would send those credentials to " + url + ". If this is intentional, supply the" + + " repository on the command line (-D user property), or re-run with -D" + + ALLOW_CREDENTIAL_REUSE_PROPERTY + "=true (user property; it cannot be set from a POM)."); + } + String requested = normalizeRepositoryUrl(url); + for (String known : knownUrls) { + if (requested.equals(normalizeRepositoryUrl(known))) { + return; + } + } + String knownList = String.join(", ", knownUrls); + if (isCredentialReuseAllowed()) { + getLog().warn("Repository id '" + id + "' binds settings.xml credentials that are on record for " + + knownList + " but the deployment targets " + url + "; proceeding because -D" + + ALLOW_CREDENTIAL_REUSE_PROPERTY + "=true is set"); + return; + } + throw new MojoException( + "Refusing to deploy: repository id '" + id + "' matches a settings.xml server entry whose" + + " credentials are on record for " + knownList + ", but the deployment would send them to " + + url + ". If this redirection is intentional, re-run with -D" + + ALLOW_CREDENTIAL_REUSE_PROPERTY + "=true (user property; it cannot be set from a POM)."); + } + + /** + * Collects the URLs this build already associates with the given repository id: mirror entries + * and profile repositories from {@code settings.xml}. Subclasses add further sources (the + * deploy goal adds the project's {@code distributionManagement}). + *

+ * Trust note: sources read from the project model (such as {@code distributionManagement}) + * are attacker-controlled in the malicious-POM model and are therefore advisory: they can + * only widen the accepted set for the mismatch check, never authorize a binding by their absence + * — the empty-record case is handled by provenance in + * {@link #validateCredentialBinding(String, String, boolean)}. Settings.xml-sourced entries + * (mirrors, profiles) are operator-controlled. + */ + protected Collection getKnownRepositoryUrls(String id) { + Collection urls = new LinkedHashSet<>(); + Settings settings = session.getSettings(); + if (settings != null) { + for (Mirror mirror : settings.getMirrors()) { + if (id.equals(mirror.getId()) && mirror.getUrl() != null) { + urls.add(mirror.getUrl()); + } + } + for (Profile profile : settings.getProfiles()) { + for (Repository repository : profile.getRepositories()) { + if (id.equals(repository.getId()) && repository.getUrl() != null) { + urls.add(repository.getUrl()); + } + } + for (Repository repository : profile.getPluginRepositories()) { + if (id.equals(repository.getId()) && repository.getUrl() != null) { + urls.add(repository.getUrl()); + } + } + } + } + return urls; + } + + private boolean isCredentialReuseAllowed() { + Map userProperties = session.getUserProperties(); + return userProperties != null && Boolean.parseBoolean(userProperties.get(ALLOW_CREDENTIAL_REUSE_PROPERTY)); + } + + static String normalizeRepositoryUrl(String url) { + String normalized = url.trim(); + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } + protected Session getSession() { return session; } diff --git a/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java b/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java index 72d5bb5f..82e281c5 100644 --- a/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java +++ b/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java @@ -117,6 +117,16 @@ public class DeployFileMojo extends AbstractDeployMojo { /** * Server Id to map on the <id> under <server> section of settings.xml In most cases, this parameter * will be required for authentication. + *

+ * Security note: the credentials looked up in settings.xml are selected purely by this id, + * so a mismatched id/url pair can point credentials kept for one server at a different URL. When this id + * matches a settings.xml server entry and the URL differs from every URL this build associates + * with the id, the deployment is refused unless -Dmaven.deploy.allowCredentialReuse=true is given + * on the command line. When no URL at all is on record for such an id, the deployment proceeds with a warning + * naming the URL the credentials will be sent to: deploy-file's parameters are command-line-supplied by + * nature, so the operator typing the pair is treated as the authorization that a POM cannot forge. + * Also note the default value remote-repository: if a server entry with that + * generic id exists in settings.xml, its credentials are used whenever this parameter is omitted. */ @Parameter(property = "repositoryId", defaultValue = "remote-repository", required = true) private String repositoryId; @@ -249,6 +259,7 @@ public void execute() throws MojoException { initProperties(); + validateCredentialBinding(repositoryId, url.replace(File.separator, "/")); RemoteRepository deploymentRepository = createDeploymentArtifactRepository(repositoryId, url.replace(File.separator, "/")); diff --git a/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java b/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java index 6e0cce63..c8f28761 100644 --- a/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java +++ b/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java @@ -86,6 +86,17 @@ public class DeployMojo extends AbstractDeployMojo { * Note: In version 2.x, the format was id::layout::url where layout * could be default (ie. Maven 2) or legacy (ie. Maven 1), but since 3.0.0 the layout part * has been removed because Maven 3 only supports Maven 2 repository layout. + *

+ * Security note: the credentials looked up in settings.xml are selected purely by the + * id part, so this parameter can point credentials kept for one server at a different URL. + * The provenance of the value matters: when the id matches a settings.xml server entry (stored + * credentials) and no URL is on record for that id in this build, a value set from the POM (a pom + * property or plugin configuration) is refused, while a value supplied on the command line + * (-DaltDeploymentRepository=...) proceeds with a warning naming the URL the credentials will + * be sent to. + * When the id matches a settings.xml server entry and the URL differs from every URL this build + * associates with that id, the deployment is refused unless + * -Dmaven.deploy.allowCredentialReuse=true is given on the command line. */ @Parameter(property = "altDeploymentRepository") private String altDeploymentRepository; @@ -334,10 +345,13 @@ RemoteRepository getDeploymentRepository(boolean isSnapshot) throws MojoExceptio String altDeploymentRepo; if (isSnapshot && altSnapshotDeploymentRepository != null) { altDeploymentRepo = altSnapshotDeploymentRepository; + altRepositoryFromUserProperty = isFromUserProperty("altSnapshotDeploymentRepository", altDeploymentRepo); } else if (!isSnapshot && altReleaseDeploymentRepository != null) { altDeploymentRepo = altReleaseDeploymentRepository; + altRepositoryFromUserProperty = isFromUserProperty("altReleaseDeploymentRepository", altDeploymentRepo); } else { altDeploymentRepo = altDeploymentRepository; + altRepositoryFromUserProperty = isFromUserProperty("altDeploymentRepository", altDeploymentRepo); } if (altDeploymentRepo != null) { @@ -353,7 +367,7 @@ RemoteRepository getDeploymentRepository(boolean isSnapshot) throws MojoExceptio if ("default".equals(layout)) { getLog().warn("Using legacy syntax for alternative repository. " + "Use \"" + id + "::" + url + "\" instead."); - repo = createDeploymentArtifactRepository(id, url); + repo = createAltDeploymentRepository(id, url); } else { throw new MojoException( altDeploymentRepo, @@ -373,7 +387,7 @@ RemoteRepository getDeploymentRepository(boolean isSnapshot) throws MojoExceptio String id = matcher.group(1).trim(); String url = matcher.group(2).trim(); - repo = createDeploymentArtifactRepository(id, url); + repo = createAltDeploymentRepository(id, url); } } } @@ -404,6 +418,68 @@ && isNotEmpty(dm.getRepository().getUrl())) { return repo; } + /** + * Creates the repository for an alternative deployment target: warns when it overrides the + * project's declared {@code distributionManagement} (naming the server id whose settings.xml + * credentials will be used) and guards the credential binding with the provenance of the + * alternative-repository value (see {@link #validateCredentialBinding(String, String, boolean)}): + * a mismatch against the URLs on record for the id is refused regardless of provenance, and a + * credentials-bearing id with no URL on record is refused when the value came from the POM but + * proceeds with a warning when the operator typed it on the command line. + */ + private RemoteRepository createAltDeploymentRepository(String id, String url) { + DistributionManagement dm = project.getModel().getDistributionManagement(); + if (dm != null && (dm.getRepository() != null || dm.getSnapshotRepository() != null)) { + getLog().warn("Alternative deployment repository overrides the distributionManagement declared by" + + " the project: credentials of server id '" + id + + "' from settings.xml (if any) will be used for " + url); + } + validateCredentialBinding(id, url, altRepositoryFromUserProperty); + return createDeploymentArtifactRepository(id, url); + } + + /** + * Whether the alternative-repository value selected by {@link #getDeploymentRepository(boolean)} + * was supplied as a {@code -D} session user property (operator-typed on the command line) rather + * than resolved from the POM (a pom property or plugin configuration). POM-sourced values are + * attacker-writable in the malicious-POM model, so they get the fail-closed treatment in + * {@link #validateCredentialBinding(String, String, boolean)}. + */ + private boolean altRepositoryFromUserProperty; + + /** + * Returns {@code true} when the given user property is present in the session and + * carries the value actually in use: Maven lets an explicit {@code } entry in the + * POM win over a {@code -D} property of the same name, so presence of the property alone does + * not prove the value's provenance. + */ + private boolean isFromUserProperty(String propertyName, String value) { + if (value == null) { + return false; + } + java.util.Map userProperties = session.getUserProperties(); + return userProperties != null && value.equals(userProperties.get(propertyName)); + } + + @Override + protected java.util.Collection getKnownRepositoryUrls(String id) { + java.util.Collection urls = super.getKnownRepositoryUrls(id); + DistributionManagement dm = project.getModel().getDistributionManagement(); + if (dm != null) { + if (dm.getRepository() != null + && id.equals(dm.getRepository().getId()) + && isNotEmpty(dm.getRepository().getUrl())) { + urls.add(dm.getRepository().getUrl()); + } + if (dm.getSnapshotRepository() != null + && id.equals(dm.getSnapshotRepository().getId()) + && isNotEmpty(dm.getSnapshotRepository().getUrl())) { + urls.add(dm.getSnapshotRepository().getUrl()); + } + } + return urls; + } + private boolean isValidPath(Artifact a) { return getArtifactManager().getPath(a).filter(Files::isRegularFile).isPresent(); } diff --git a/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java b/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java index 5646b85e..a794daf1 100644 --- a/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java +++ b/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java @@ -46,6 +46,9 @@ import org.apache.maven.api.services.ArtifactManager; import org.apache.maven.api.services.ProjectManager; import org.apache.maven.api.services.RepositoryFactory; +import org.apache.maven.api.settings.Mirror; +import org.apache.maven.api.settings.Server; +import org.apache.maven.api.settings.Settings; import org.apache.maven.impl.InternalSession; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -268,6 +271,126 @@ void altReleaseDeploymentRepository(DeployMojo mojo) throws Exception { assertEquals("http://localhost", repository.getUrl()); } + @Test + @InjectMojo(goal = "deploy") + void altDeploymentRepositoryRefusedWhenCredentialsBoundToOtherUrl(DeployMojo mojo) throws Exception { + when(session.getSettings()) + .thenReturn(Settings.newBuilder() + .servers(List.of(Server.newBuilder().id("remote-repo").build())) + .mirrors(List.of(Mirror.newBuilder() + .id("remote-repo") + .url("https://good.example/repo") + .build())) + .build()); + setVariableValueToObject(mojo, "altDeploymentRepository", "remote-repo::https://evil.example/repo"); + + MojoException e = assertThrows(MojoException.class, () -> mojo.getDeploymentRepository(false)); + assertTrue(e.getMessage().contains("Refusing to deploy"), e.getMessage()); + assertTrue(e.getMessage().contains("https://evil.example/repo"), e.getMessage()); + assertTrue(e.getMessage().contains("https://good.example/repo"), e.getMessage()); + } + + @Test + @InjectMojo(goal = "deploy") + void altDeploymentRepositoryAcceptedWhenUrlKnownForId(DeployMojo mojo) throws Exception { + when(session.getSettings()) + .thenReturn(Settings.newBuilder() + .servers(List.of(Server.newBuilder().id("remote-repo").build())) + .build()); + // the project's own distributionManagement URL for "remote-repo" is a known binding + String dmUrl = Paths.get(getBasedir()).toUri().toString(); + setVariableValueToObject(mojo, "altDeploymentRepository", "remote-repo::" + dmUrl); + + RemoteRepository repository = mojo.getDeploymentRepository(false); + assertEquals("remote-repo", repository.getId()); + } + + @Test + @InjectMojo(goal = "deploy") + void altDeploymentRepositoryCredentialReuseOptOut(DeployMojo mojo) throws Exception { + when(session.getSettings()) + .thenReturn(Settings.newBuilder() + .servers(List.of(Server.newBuilder().id("remote-repo").build())) + .mirrors(List.of(Mirror.newBuilder() + .id("remote-repo") + .url("https://good.example/repo") + .build())) + .build()); + session.getUserProperties().put(AbstractDeployMojo.ALLOW_CREDENTIAL_REUSE_PROPERTY, "true"); + setVariableValueToObject(mojo, "altDeploymentRepository", "remote-repo::https://evil.example/repo"); + + RemoteRepository repository = mojo.getDeploymentRepository(false); + assertEquals("https://evil.example/repo", repository.getUrl()); + } + + @Test + @InjectMojo(goal = "deploy") + void pomSourcedAltRepositoryRefusedWhenNoUrlOnRecordForCredentialedId(DeployMojo mojo) throws Exception { + // the mainline attack shape: settings.xml stores credentials for "ossrh" but binds no URL + // to that id anywhere (no mirror, no profile repository, and the project's + // distributionManagement uses a different id), and a POM-bindable parameter pairs the id + // with an attacker URL. The parameter is set without a matching -D user property, which is + // exactly what a pom entry or plugin produces. + when(session.getSettings()) + .thenReturn(Settings.newBuilder() + .servers(List.of(Server.newBuilder().id("ossrh").build())) + .build()); + setVariableValueToObject(mojo, "altDeploymentRepository", "ossrh::https://evil.example/repo"); + + MojoException e = assertThrows(MojoException.class, () -> mojo.getDeploymentRepository(false)); + assertTrue(e.getMessage().contains("Refusing to deploy"), e.getMessage()); + assertTrue(e.getMessage().contains("'ossrh'"), e.getMessage()); + assertTrue(e.getMessage().contains("https://evil.example/repo"), e.getMessage()); + assertTrue(e.getMessage().contains("configured from the POM"), e.getMessage()); + } + + @Test + @InjectMojo(goal = "deploy") + void cliSourcedAltRepositoryWarnsButProceedsWhenNoUrlOnRecordForCredentialedId(DeployMojo mojo) throws Exception { + // same empty-record shape, but the operator typed the pair on the command line: the value + // is present as a session user property and matches the parameter value, so the binding + // proceeds (with a warning naming the target URL) - fully failing closed here would break + // legitimate -DaltDeploymentRepository workflows against ids that settings.xml only stores + // credentials for + when(session.getSettings()) + .thenReturn(Settings.newBuilder() + .servers(List.of(Server.newBuilder().id("ossrh").build())) + .build()); + session.getUserProperties().put("altDeploymentRepository", "ossrh::https://elsewhere.example/repo"); + setVariableValueToObject(mojo, "altDeploymentRepository", "ossrh::https://elsewhere.example/repo"); + + RemoteRepository repository = mojo.getDeploymentRepository(false); + assertEquals("ossrh", repository.getId()); + assertEquals("https://elsewhere.example/repo", repository.getUrl()); + } + + @Test + @InjectMojo(goal = "deploy") + void pomSourcedAltRepositoryEmptyRecordRefusalHasKnobOverride(DeployMojo mojo) throws Exception { + when(session.getSettings()) + .thenReturn(Settings.newBuilder() + .servers(List.of(Server.newBuilder().id("ossrh").build())) + .build()); + session.getUserProperties().put(AbstractDeployMojo.ALLOW_CREDENTIAL_REUSE_PROPERTY, "true"); + setVariableValueToObject(mojo, "altDeploymentRepository", "ossrh::https://evil.example/repo"); + + RemoteRepository repository = mojo.getDeploymentRepository(false); + assertEquals("https://evil.example/repo", repository.getUrl()); + } + + @Test + @InjectMojo(goal = "deploy") + void altDeploymentRepositoryAcceptedWhenIdHasNoCredentials(DeployMojo mojo) throws Exception { + when(session.getSettings()) + .thenReturn(Settings.newBuilder() + .servers(List.of(Server.newBuilder().id("other-server").build())) + .build()); + setVariableValueToObject(mojo, "altDeploymentRepository", "no-creds-repo::https://elsewhere.example/repo"); + + RemoteRepository repository = mojo.getDeploymentRepository(false); + assertEquals("https://elsewhere.example/repo", repository.getUrl()); + } + private ArtifactDeployerRequest execute(DeployMojo mojo) { ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(ArtifactDeployerRequest.class); doNothing().when(artifactDeployer).deploy(requestCaptor.capture()); From 2d4d30e586436f9d9a4bf85a895deb33a7bc44cc Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 31 Aug 2026 13:46:25 +0200 Subject: [PATCH 02/18] =?UTF-8?q?fix:=20f002=20(MEDIUM)=20=E2=80=94=20Sing?= =?UTF-8?q?le=20choke=20point:=20AbstractDeployMojo.validateTransportSecur?= =?UTF-8?q?ity(id,url)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security audit finding f002 (MEDIUM). Single choke point: AbstractDeployMojo.validateTransportSecurity(id,url) refuses http:// and ftp:// deployment URLs by default, called from createDeploymentArtifactRepository (covers the alt-repo route in DeployMojo and the deploy-file url) and explicitly on both distributionManagement branches (which bypass that factory via session.createRemoteRepository(Repository)). Loopback hosts (localhost, 127.0.0.0/8, ::1) are exempt so local mock/IT repositories keep working; opt-out is -Dmaven.deploy.allowInsecureUrl=true (user property, non-pom-bindable), which downgrades the refusal to a WARN. Unknown/other schemes (https, file, scp, sftp, scm:, dav:) are untouched. Docs leg: deploy-http.md example switched to https with an explicit prefer-HTTPS + refusal note; deploying-with-classifiers.md.vm http:// examples switched to https. (Wagon-era deploy-ftp.md/deploy-ssh-external.md rewrites are in bug_13.) Co-Authored-By: Claude Opus 4.6 --- .../plugins/deploy/AbstractDeployMojo.java | 72 +++++++++++++++++++ .../maven/plugins/deploy/DeployMojo.java | 5 ++ src/site/markdown/examples/deploy-http.md | 8 ++- .../examples/deploying-with-classifiers.md.vm | 4 +- .../maven/plugins/deploy/DeployMojoTest.java | 37 ++++++++++ 5 files changed, 123 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/apache/maven/plugins/deploy/AbstractDeployMojo.java b/src/main/java/org/apache/maven/plugins/deploy/AbstractDeployMojo.java index 617f8810..7ab8339a 100644 --- a/src/main/java/org/apache/maven/plugins/deploy/AbstractDeployMojo.java +++ b/src/main/java/org/apache/maven/plugins/deploy/AbstractDeployMojo.java @@ -51,6 +51,12 @@ public abstract class AbstractDeployMojo implements Mojo { */ static final String ALLOW_CREDENTIAL_REUSE_PROPERTY = "maven.deploy.allowCredentialReuse"; + /** + * User property (not settable from the POM) that allows deploying to cleartext (http/ftp) + * URLs. Loopback hosts are always exempt from the cleartext check. + */ + static final String ALLOW_INSECURE_URL_PROPERTY = "maven.deploy.allowInsecureUrl"; + @Inject protected Log logger; @@ -105,9 +111,75 @@ protected void warnIfAffectedPackagingAndMaven(String packaging) { * Creates resolver {@link RemoteRepository} equipped with needed whistles and bells. */ protected RemoteRepository createDeploymentArtifactRepository(String id, String url) { + validateTransportSecurity(id, url); return getSession().createRemoteRepository(id, url); } + /** + * Refuses cleartext deployment transports: with an {@code http://} or {@code ftp://} deployment + * URL, the HTTP Basic (or FTP) credentials resolved for {@code id} and all deployed artifacts + * would cross the network unencrypted. Loopback hosts are exempt (local mock/test repositories); + * everything else requires an explicit {@code -D}{@value #ALLOW_INSECURE_URL_PROPERTY}{@code =true} + * opt-out. Maven core's {@code external:http:*} mirror blocking covers dependency + * resolution only; this is the deployment-side counterpart. + * + * @param id the repository id (used in diagnostics) + * @param url the deployment URL + * @throws MojoException when the URL is cleartext, non-loopback, and not explicitly allowed + */ + protected void validateTransportSecurity(String id, String url) throws MojoException { + if (url == null || !isInsecureDeploymentUrl(url)) { + return; + } + Map userProperties = session.getUserProperties(); + if (userProperties != null && Boolean.parseBoolean(userProperties.get(ALLOW_INSECURE_URL_PROPERTY))) { + getLog().warn("Deploying to insecure (cleartext) URL " + url + " for repository id '" + id + + "' because -D" + ALLOW_INSECURE_URL_PROPERTY + + "=true is set: credentials and artifacts will cross the network unencrypted"); + return; + } + throw new MojoException("Refusing to deploy to insecure (cleartext) URL " + url + " for repository id '" + id + + "': credentials and artifacts would cross the network unencrypted. Use an https:// endpoint," + + " or re-run with -D" + ALLOW_INSECURE_URL_PROPERTY + + "=true to accept the risk (loopback hosts are exempt from this check)."); + } + + /** + * Returns {@code true} for cleartext ({@code http}/{@code ftp}) URLs targeting a non-loopback host. + */ + static boolean isInsecureDeploymentUrl(String url) { + int colon = url.indexOf(':'); + if (colon <= 0) { + return false; + } + String scheme = url.substring(0, colon).toLowerCase(java.util.Locale.ROOT); + if (!"http".equals(scheme) && !"ftp".equals(scheme)) { + return false; + } + return !isLoopbackHost(hostOf(url)); + } + + private static String hostOf(String url) { + try { + return java.net.URI.create(url).getHost(); + } catch (IllegalArgumentException e) { + return null; + } + } + + static boolean isLoopbackHost(String host) { + if (host == null || host.isEmpty()) { + return false; + } + if (host.startsWith("[") && host.endsWith("]")) { + host = host.substring(1, host.length() - 1); + } + return "localhost".equalsIgnoreCase(host) + || host.startsWith("127.") + || "::1".equals(host) + || "0:0:0:0:0:0:0:1".equals(host); + } + /** * Guards the repository id→URL credential binding: Maven resolves the credentials for a * deployment repository purely by matching its id against a {@code } entry in diff --git a/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java b/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java index c8f28761..77588936 100644 --- a/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java +++ b/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java @@ -399,10 +399,15 @@ RemoteRepository getDeploymentRepository(boolean isSnapshot) throws MojoExceptio && dm.getSnapshotRepository() != null && isNotEmpty(dm.getSnapshotRepository().getId()) && isNotEmpty(dm.getSnapshotRepository().getUrl())) { + validateTransportSecurity( + dm.getSnapshotRepository().getId(), + dm.getSnapshotRepository().getUrl()); repo = session.createRemoteRepository(dm.getSnapshotRepository()); } else if (dm.getRepository() != null && isNotEmpty(dm.getRepository().getId()) && isNotEmpty(dm.getRepository().getUrl())) { + validateTransportSecurity( + dm.getRepository().getId(), dm.getRepository().getUrl()); repo = session.createRemoteRepository(dm.getRepository()); } } diff --git a/src/site/markdown/examples/deploy-http.md b/src/site/markdown/examples/deploy-http.md index 2289b16a..f795713c 100644 --- a/src/site/markdown/examples/deploy-http.md +++ b/src/site/markdown/examples/deploy-http.md @@ -34,13 +34,19 @@ In order to deploy artifacts using HTTP(S) you must specify the use of an HTTP(S my-mrm-relases - http://localhost:8081/nexus/content/repositories/release + https://repomanager.example.com/nexus/content/repositories/release ... ``` +**Always prefer `https://` deployment URLs.** With a cleartext `http://` URL the credentials +configured below are sent as unencrypted HTTP Basic authentication and can be captured by anyone +on the network path. The plugin therefore refuses cleartext `http://` (and `ftp://`) deployment +URLs by default; loopback hosts (`localhost`, `127.0.0.1`) are exempt, and the check can be +explicitly overridden with `-Dmaven.deploy.allowInsecureUrl=true` if you accept the risk. + ## Authentication Your `settings.xml` would contain a `server` element where the `id` of that element matches `id` of the HTTP(S) repository specified in the POM above. It must contain the credentials to be used (in [encrypted form](https://maven.apache.org/guides/mini/guide-encryption.html)): diff --git a/src/site/markdown/examples/deploying-with-classifiers.md.vm b/src/site/markdown/examples/deploying-with-classifiers.md.vm index 7cdc5b0e..a57af408 100644 --- a/src/site/markdown/examples/deploying-with-classifiers.md.vm +++ b/src/site/markdown/examples/deploying-with-classifiers.md.vm @@ -37,7 +37,7 @@ For example: from the following artifact names, the classifier is located betwee You can deploy the main artifact and the classified artifacts in a single run. Let's assume the original filename for the documentation is `site.pdf`: ```unknown -mvn ${project.groupId}:${project.artifactId}:${project.version}:deploy-file -Durl=http://localhost:8081/repomanager/ \ +mvn ${project.groupId}:${project.artifactId}:${project.version}:deploy-file -Durl=https://repomanager.example.com/repo/ \ -DrepositoryId=some.id \ -Dfile=path/to/artifact-name-1.0.jar \ -DpomFile=path-to-your-pom.xml \ @@ -49,7 +49,7 @@ mvn ${project.groupId}:${project.artifactId}:${project.version}:deploy-file -Dur If you only want to deploy the `debug`\-jar and want to keep the classifier, you can execute the `deploy-file` like ```unknown -mvn ${project.groupId}:${project.artifactId}:${project.version}:deploy-file -Durl=http://localhost:8081/repomanager/ \ +mvn ${project.groupId}:${project.artifactId}:${project.version}:deploy-file -Durl=https://repomanager.example.com/repo/ \ -DrepositoryId=some.id \ -Dfile=path-to-your-artifact-jar \ -DpomFile=path-to-your-pom.xml \ diff --git a/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java b/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java index a794daf1..48961dea 100644 --- a/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java +++ b/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java @@ -391,6 +391,43 @@ void altDeploymentRepositoryAcceptedWhenIdHasNoCredentials(DeployMojo mojo) thro assertEquals("https://elsewhere.example/repo", repository.getUrl()); } + @Test + @InjectMojo(goal = "deploy") + void insecureHttpAltDeploymentRepositoryRefused(DeployMojo mojo) throws Exception { + setVariableValueToObject(mojo, "altDeploymentRepository", "insecure-repo::http://insecure.example/repo"); + + MojoException e = assertThrows(MojoException.class, () -> mojo.getDeploymentRepository(false)); + assertTrue(e.getMessage().contains("insecure (cleartext) URL"), e.getMessage()); + } + + @Test + @InjectMojo(goal = "deploy") + void insecureFtpAltDeploymentRepositoryRefused(DeployMojo mojo) throws Exception { + setVariableValueToObject(mojo, "altDeploymentRepository", "insecure-repo::ftp://insecure.example/repo"); + + MojoException e = assertThrows(MojoException.class, () -> mojo.getDeploymentRepository(false)); + assertTrue(e.getMessage().contains("insecure (cleartext) URL"), e.getMessage()); + } + + @Test + @InjectMojo(goal = "deploy") + void insecureAltDeploymentRepositoryOptOut(DeployMojo mojo) throws Exception { + session.getUserProperties().put(AbstractDeployMojo.ALLOW_INSECURE_URL_PROPERTY, "true"); + setVariableValueToObject(mojo, "altDeploymentRepository", "insecure-repo::http://insecure.example/repo"); + + RemoteRepository repository = mojo.getDeploymentRepository(false); + assertEquals("http://insecure.example/repo", repository.getUrl()); + } + + @Test + @InjectMojo(goal = "deploy") + void loopbackHttpAltDeploymentRepositoryAccepted(DeployMojo mojo) throws Exception { + setVariableValueToObject(mojo, "altDeploymentRepository", "local-repo::http://127.0.0.1:8081/repo"); + + RemoteRepository repository = mojo.getDeploymentRepository(false); + assertEquals("http://127.0.0.1:8081/repo", repository.getUrl()); + } + private ArtifactDeployerRequest execute(DeployMojo mojo) { ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(ArtifactDeployerRequest.class); doNothing().when(artifactDeployer).deploy(requestCaptor.capture()); From 0e15f77fb479bbedd814b1539f83e656e43d1d5f Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 31 Aug 2026 13:46:26 +0200 Subject: [PATCH 03/18] =?UTF-8?q?fix:=20f003=20(MEDIUM)=20=E2=80=94=20Both?= =?UTF-8?q?=20legs=20of=20the=20root=20cause=20closed=20with=20shared=20va?= =?UTF-8?q?lidators=20hoisted=20to=20Abs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security audit finding f003 (MEDIUM). Both legs of the root cause closed with shared validators hoisted to AbstractDeployMojo: isValidId now rejects any empty dot-separated segment (kills '.', '..', '.a', 'a.', 'a..b' while keeping the [a-zA-Z0-9._-] allowlist); isValidVersion keeps the separator blocklist and additionally rejects empty, whitespace/ISO-control characters, and all-dots values; new isValidClassifier (same allowlist, not all-dots, absent/empty allowed) and isValidTypeOrExtension (non-empty variant) cover the components the old gate never saw. DeployFileMojo now validates packaging and classifier at the :272 gate (after processModel, so jar-embedded-POM-supplied packaging is covered too) and every classifiers/types list entry inside the side-artifact loop (empty entries rejected). DeployMojo (second mojo per the finding brief) validates project g/a/v and every deployable's classifier in createDeployerRequest before building the request. Co-Authored-By: Claude Opus 4.6 --- .../plugins/deploy/AbstractDeployMojo.java | 91 +++++++++++++++++++ .../maven/plugins/deploy/DeployFileMojo.java | 57 ++++-------- .../maven/plugins/deploy/DeployMojo.java | 11 +++ .../deploy/DeployFileMojoUnitTest.java | 48 ++++++++++ 4 files changed, 169 insertions(+), 38 deletions(-) diff --git a/src/main/java/org/apache/maven/plugins/deploy/AbstractDeployMojo.java b/src/main/java/org/apache/maven/plugins/deploy/AbstractDeployMojo.java index 7ab8339a..e9039cbd 100644 --- a/src/main/java/org/apache/maven/plugins/deploy/AbstractDeployMojo.java +++ b/src/main/java/org/apache/maven/plugins/deploy/AbstractDeployMojo.java @@ -180,6 +180,97 @@ static boolean isLoopbackHost(String host) { || "0:0:0:0:0:0:0:1".equals(host); } + static final String ILLEGAL_VERSION_CHARS = "\\/:\"<>|?*[](){},"; + + /** + * Returns {@code true} if passed in string is "valid Maven ID" (groupId or artifactId): only + * {@code [a-zA-Z0-9._-]} characters, and no empty dot-separated segment (which rejects values + * that are entirely dots such as {@code ".."} - a whole repository-layout path segment - as + * well as leading/trailing/consecutive dots). + */ + static boolean isValidId(String id) { + if (id == null || id.isEmpty()) { + return false; + } + for (int i = 0; i < id.length(); i++) { + char c = id.charAt(i); + if (!(c >= 'a' && c <= 'z' + || c >= 'A' && c <= 'Z' + || c >= '0' && c <= '9' + || c == '-' + || c == '_' + || c == '.')) { + return false; + } + } + for (String segment : id.split("\\.", -1)) { + if (segment.isEmpty()) { + return false; + } + } + return true; + } + + /** + * Returns {@code true} if passed in string is "valid Maven (simple, non range, expression, etc.) + * version": no path/separator-dangerous characters, no whitespace or control characters, and not + * composed entirely of dots (a version is used verbatim as a whole repository-layout path + * segment, so {@code ".."} must not pass). + */ + static boolean isValidVersion(String version) { + if (version == null || version.isEmpty()) { + return false; + } + boolean seenNonDot = false; + for (int i = version.length() - 1; i >= 0; i--) { + char c = version.charAt(i); + if (ILLEGAL_VERSION_CHARS.indexOf(c) >= 0 || Character.isWhitespace(c) || Character.isISOControl(c)) { + return false; + } + if (c != '.') { + seenNonDot = true; + } + } + return seenNonDot; + } + + /** + * Returns {@code true} if the passed classifier is absent, empty, or layout-safe: the classifier + * is embedded verbatim in the repository-layout file name + * ({@code artifactId-version-classifier.extension}), so it must use the same character allowlist + * as ids and must not be composed entirely of dots. + */ + static boolean isValidClassifier(String classifier) { + if (classifier == null || classifier.isEmpty()) { + return true; + } + boolean seenNonDot = false; + for (int i = 0; i < classifier.length(); i++) { + char c = classifier.charAt(i); + if (!(c >= 'a' && c <= 'z' + || c >= 'A' && c <= 'Z' + || c >= '0' && c <= '9' + || c == '-' + || c == '_' + || c == '.')) { + return false; + } + if (c != '.') { + seenNonDot = true; + } + } + return seenNonDot; + } + + /** + * Returns {@code true} if the passed artifact type / extension / packaging is layout-safe: + * non-empty, id character allowlist, not composed entirely of dots (the extension is appended + * to the repository-layout file name). + */ + static boolean isValidTypeOrExtension(String type) { + return type != null && !type.isEmpty() && isValidClassifier(type); + } + /** * Guards the repository id→URL credential binding: Maven resolves the credentials for a * deployment repository purely by matching its id against a {@code } entry in diff --git a/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java b/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java index 82e281c5..0ea359bc 100644 --- a/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java +++ b/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java @@ -57,7 +57,6 @@ @SuppressWarnings("unused") public class DeployFileMojo extends AbstractDeployMojo { private static final String TAR = "tar."; - private static final String ILLEGAL_VERSION_CHARS = "\\/:\"<>|?*[](){},"; /** * GroupId of the artifact to be deployed. Retrieved from POM file if specified. @@ -284,6 +283,14 @@ public void execute() throws MojoException { throw new MojoException("The artifact information is not valid: uses invalid characters."); } + if (!isValidTypeOrExtension(packaging)) { + throw new MojoException("The packaging is not valid: uses invalid characters."); + } + + if (!isValidClassifier(classifier)) { + throw new MojoException("The classifier is not valid: uses invalid characters."); + } + failIfOffline(); warnIfAffectedPackagingAndMaven(packaging); @@ -375,12 +382,22 @@ public void execute() throws MojoException { if (Files.isRegularFile(file)) { String extension = getExtension(file); String type = types.substring(ti, nti).trim(); + String classifierEntry = classifiers.substring(ci, nci).trim(); + + if (!isValidTypeOrExtension(type)) { + throw new MojoException("The 'types' entry '" + type + "' is not valid:" + + " uses invalid characters or is empty."); + } + if (classifierEntry.isEmpty() || !isValidClassifier(classifierEntry)) { + throw new MojoException("The 'classifiers' entry '" + classifierEntry + "' is not valid:" + + " uses invalid characters or is empty."); + } ProducedArtifact deployable = session.createProducedArtifact( artifact.getGroupId(), artifact.getArtifactId(), artifact.getVersion().toString(), - classifiers.substring(ci, nci).trim(), + classifierEntry, extension, type); artifactManager.setPath(deployable, file); @@ -592,40 +609,4 @@ private String getExtension(final Path file) { } return ""; } - - /** - * Returns {@code true} if passed in string is "valid Maven ID" (groupId or artifactId). - */ - private boolean isValidId(String id) { - if (id == null) { - return false; - } - for (int i = 0; i < id.length(); i++) { - char c = id.charAt(i); - if (!(c >= 'a' && c <= 'z' - || c >= 'A' && c <= 'Z' - || c >= '0' && c <= '9' - || c == '-' - || c == '_' - || c == '.')) { - return false; - } - } - return true; - } - - /** - * Returns {@code true} if passed in string is "valid Maven (simple. non range, expression, etc) version". - */ - private boolean isValidVersion(String version) { - if (version == null) { - return false; - } - for (int i = version.length() - 1; i >= 0; i--) { - if (ILLEGAL_VERSION_CHARS.indexOf(version.charAt(i)) >= 0) { - return false; - } - } - return true; - } } diff --git a/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java b/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java index 77588936..1df99d07 100644 --- a/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java +++ b/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java @@ -299,7 +299,18 @@ private ArtifactDeployerRequest createDeployerRequest() { artifactManager.setPath(project.getPomArtifact(), project.getPomPath()); } + if (!isValidId(project.getGroupId()) + || !isValidId(project.getArtifactId()) + || !isValidVersion(project.getVersion())) { + throw new MojoException("The project coordinates " + project.getGroupId() + ":" + project.getArtifactId() + + ":" + project.getVersion() + " are not valid: they use invalid characters."); + } + for (Artifact deployable : deployables) { + if (!isValidClassifier(deployable.getClassifier())) { + throw new MojoException("The classifier of attached artifact " + deployable + + " is not valid: uses invalid characters."); + } if (!isValidPath(deployable)) { if (deployable == project.getMainArtifact().orElse(null)) { if (attachedArtifacts.isEmpty()) { diff --git a/src/test/java/org/apache/maven/plugins/deploy/DeployFileMojoUnitTest.java b/src/test/java/org/apache/maven/plugins/deploy/DeployFileMojoUnitTest.java index 6a96c4e4..21f41759 100644 --- a/src/test/java/org/apache/maven/plugins/deploy/DeployFileMojoUnitTest.java +++ b/src/test/java/org/apache/maven/plugins/deploy/DeployFileMojoUnitTest.java @@ -28,6 +28,8 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * @author Jerome Lacoste @@ -107,6 +109,52 @@ private void checkMojoProperties( assertEquals(expectedPackaging, mojo.getPackaging()); } + @Test + void idValidationRejectsDotOnlyAndEmptySegments() { + assertFalse(AbstractDeployMojo.isValidId(".")); + assertFalse(AbstractDeployMojo.isValidId("..")); + assertFalse(AbstractDeployMojo.isValidId(".a")); + assertFalse(AbstractDeployMojo.isValidId("a.")); + assertFalse(AbstractDeployMojo.isValidId("a..b")); + assertFalse(AbstractDeployMojo.isValidId("a/b")); + assertFalse(AbstractDeployMojo.isValidId("a\\b")); + assertFalse(AbstractDeployMojo.isValidId("")); + assertFalse(AbstractDeployMojo.isValidId(null)); + assertTrue(AbstractDeployMojo.isValidId("org.apache.maven")); + assertTrue(AbstractDeployMojo.isValidId("maven-deploy-plugin")); + } + + @Test + void versionValidationRejectsDotOnlyWhitespaceAndControlChars() { + assertFalse(AbstractDeployMojo.isValidVersion(".")); + assertFalse(AbstractDeployMojo.isValidVersion("..")); + assertFalse(AbstractDeployMojo.isValidVersion("1.0 ")); + assertFalse(AbstractDeployMojo.isValidVersion("1\t0")); + assertFalse(AbstractDeployMojo.isValidVersion("1.0/x")); + assertFalse(AbstractDeployMojo.isValidVersion("")); + assertFalse(AbstractDeployMojo.isValidVersion(null)); + assertTrue(AbstractDeployMojo.isValidVersion("1.0-SNAPSHOT")); + assertTrue(AbstractDeployMojo.isValidVersion("4.0.0-beta-3")); + } + + @Test + void classifierAndTypeValidationRejectsLayoutTraversal() { + assertFalse(AbstractDeployMojo.isValidClassifier("../../../../org/other/1.0/other-1.0")); + assertFalse(AbstractDeployMojo.isValidClassifier("..")); + assertFalse(AbstractDeployMojo.isValidClassifier("a b")); + assertTrue(AbstractDeployMojo.isValidClassifier(null)); + assertTrue(AbstractDeployMojo.isValidClassifier("")); + assertTrue(AbstractDeployMojo.isValidClassifier("sources")); + assertTrue(AbstractDeployMojo.isValidClassifier("site.pdf")); + assertFalse(AbstractDeployMojo.isValidTypeOrExtension(null)); + assertFalse(AbstractDeployMojo.isValidTypeOrExtension("")); + assertFalse(AbstractDeployMojo.isValidTypeOrExtension("..")); + assertFalse(AbstractDeployMojo.isValidTypeOrExtension("jar/../x")); + assertTrue(AbstractDeployMojo.isValidTypeOrExtension("jar")); + assertTrue(AbstractDeployMojo.isValidTypeOrExtension("tar.gz")); + assertTrue(AbstractDeployMojo.isValidTypeOrExtension("maven-plugin")); + } + private void setMojoModel( MockDeployFileMojo mojo, String group, String artifact, String version, String packaging, Parent parent) { mojo.model = Model.newBuilder() From 21207aa70331c0b5ca03aea0d38d535f6a1e18f0 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 31 Aug 2026 13:46:26 +0200 Subject: [PATCH 04/18] =?UTF-8?q?fix:=20f004=20(MEDIUM)=20=E2=80=94=20Two-?= =?UTF-8?q?sided=20state=20fix,=20per=20the=20triage=20recommendation:=20(?= =?UTF-8?q?1)=20deployAllAtOnce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security audit finding f004 (MEDIUM). Two-sided state fix, per the triage recommendation: (1) deployAllAtOnce collects the batched projects while flattening and, after all requests deploy successfully, marks each one State.DEPLOYED (mark-after-batch-deploy per the design guidance - a failed batch stays TO_BE_DEPLOYED so a genuine retry of a failed build is not suppressed); (2) execute() treats DEPLOYED as terminal: re-entering the goal for an already-deployed project logs and returns, closing both triggers (double-bound executions and the direct deploy:deploy O(N^2) walk). Co-Authored-By: Claude Opus 4.6 --- .../maven/plugins/deploy/DeployMojo.java | 20 ++++++++++++++ .../maven/plugins/deploy/DeployMojoTest.java | 27 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java b/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java index 1df99d07..0d075167 100644 --- a/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java +++ b/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java @@ -162,6 +162,10 @@ private enum State { public DeployMojo() {} private void putState(State state) { + putState(project, state); + } + + private void putState(Project project, State state) { session.getPluginContext(project).put(State.class.getName(), state); } @@ -178,6 +182,14 @@ private boolean hasState(Project project) { } public void execute() { + if (getState(project) == State.DEPLOYED) { + // Terminal state: the project was already deployed in this session, either individually + // or as part of an earlier deploy-at-end batch. Re-entering (a second bound deploy + // execution, or a direct deploy:deploy invocation) must not publish it a second time. + getLog().info("Skipping deploy for " + project.getGroupId() + ":" + project.getArtifactId() + ":" + + project.getVersion() + ": it has already been deployed in this session"); + return; + } if (Boolean.parseBoolean(skip) || ("releases".equals(skip) && !session.isVersionSnapshot(project.getVersion())) || ("snapshots".equals(skip) && session.isVersionSnapshot(project.getVersion()))) { @@ -244,6 +256,7 @@ private boolean hasDeployExecution(Project p) { private void deployAllAtOnce() { Map>> flattenedRequests = new LinkedHashMap<>(); + List batchedProjects = new ArrayList<>(); // flatten requests, grouping by remote repository and number of retries for (Project reactorProject : session.getProjects()) { State state = getState(reactorProject); @@ -254,6 +267,7 @@ private void deployAllAtOnce() { .computeIfAbsent(request.getRepository(), r -> new LinkedHashMap<>()) .computeIfAbsent(request.getRetryFailedDeploymentCount(), i -> new ArrayList<>()) .addAll(request.getArtifacts()); + batchedProjects.add(reactorProject); } } // Re-group all requests @@ -275,6 +289,12 @@ private void deployAllAtOnce() { } else { getLog().info("No actual deploy requests"); } + // Mark every batched project DEPLOYED so a re-triggered batch (second bound deploy + // execution, or a direct deploy:deploy invocation walking the reactor) cannot publish + // the same artifacts a second time. Only reached when all requests deployed successfully. + for (Project reactorProject : batchedProjects) { + putState(reactorProject, State.DEPLOYED); + } } private void deploy(ArtifactDeployerRequest request) { diff --git a/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java b/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java index 48961dea..4709b08d 100644 --- a/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java +++ b/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java @@ -22,7 +22,9 @@ import java.nio.file.Paths; import java.util.Arrays; import java.util.Collection; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import org.apache.maven.api.Artifact; @@ -428,6 +430,31 @@ void loopbackHttpAltDeploymentRepositoryAccepted(DeployMojo mojo) throws Excepti assertEquals("http://127.0.0.1:8081/repo", repository.getUrl()); } + @Test + @InjectMojo(goal = "deploy") + void deployAtEndBatchIsNotRedeployedOnReentry(DeployMojo mojo) throws Exception { + Project project = (Project) getVariableValueFromObject(mojo, "project"); + artifactManager.setPath( + project.getMainArtifact().get(), + Paths.get(getBasedir(), "target/test-classes/unit/maven-deploy-test-1.0-SNAPSHOT.jar")); + // give the session a persistent plugin context and a real reactor project list + Map> contexts = new HashMap<>(); + when(session.getPluginContext(any(Project.class))) + .thenAnswer(iom -> contexts.computeIfAbsent(iom.getArgument(0, Project.class), p -> new HashMap<>())); + when(session.getProjects()).thenReturn(List.of(project)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ArtifactDeployerRequest.class); + doNothing().when(artifactDeployer).deploy(captor.capture()); + + // deployAtEnd defaults to true: the single-project batch fires within the first execution + mojo.execute(); + assertEquals(1, captor.getAllValues().size(), "batch must fire exactly once"); + + // re-entry (second bound deploy execution, or direct deploy:deploy) must be a no-op + mojo.execute(); + assertEquals(1, captor.getAllValues().size(), "re-entry must not re-deploy the batch"); + } + private ArtifactDeployerRequest execute(DeployMojo mojo) { ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(ArtifactDeployerRequest.class); doNothing().when(artifactDeployer).deploy(requestCaptor.capture()); From 4155d95cda7c4266df6acc76600c8184be384386 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 31 Aug 2026 13:46:27 +0200 Subject: [PATCH 05/18] =?UTF-8?q?fix:=20f005=20(MEDIUM)=20=E2=80=94=20A=20?= =?UTF-8?q?single=20class-level=20monitor=20(DEPLOY=5FAT=5FEND=5FLOCK,=20a?= =?UTF-8?q?=20constant=20lock=20object?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security audit finding f005 (MEDIUM). A single class-level monitor (DEPLOY_AT_END_LOCK, a constant lock object - not mutable static state; batch state stays in per-project session plugin contexts) now serializes every state read/write and the batch trigger: the terminal-state re-entry check, the SKIPPED/DEPLOYED/TO_BE_DEPLOYED marks, and the allProjectsMarked()+deployAllAtOnce() check-then-act. The lock is deliberately held across deployAllAtOnce(): the losing thread waits, then observes the DEPLOYED states written by the winner (bug_04) and no-ops. Request construction (createDeployerRequest) stays outside the lock to keep contention minimal. Co-Authored-By: Claude Opus 4.6 --- .../maven/plugins/deploy/DeployMojo.java | 62 ++++++++++++++----- 1 file changed, 45 insertions(+), 17 deletions(-) diff --git a/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java b/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java index 0d075167..381675d8 100644 --- a/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java +++ b/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java @@ -159,6 +159,19 @@ private enum State { private static final String PROJECTS_WITH_DEPLOY_KEY = DeployMojo.class.getName() + ".projectsWithDeploy"; + /** + * Serializes the deploy-at-end mark-then-check-then-fire sequence across reactor threads. + * With {@code -T}, two reactor leaves can reach their deploy phase concurrently, both record + * their state, both see {@link #allProjectsMarked()} true, and both fire + * {@link #deployAllAtOnce()} - double-publishing every batched module (MDEPLOY-169 ships + * {@code -T2} + deployAtEnd as a supported configuration). All state reads/writes and the + * batch trigger below take this monitor, so exactly one thread fires the batch; the loser + * then observes the {@code DEPLOYED} states written by the winner and no-ops. This is a + * constant lock object, not mutable static state; the batch state itself stays in the + * per-project session plugin contexts. + */ + private static final Object DEPLOY_AT_END_LOCK = new Object(); + public DeployMojo() {} private void putState(State state) { @@ -182,19 +195,23 @@ private boolean hasState(Project project) { } public void execute() { - if (getState(project) == State.DEPLOYED) { - // Terminal state: the project was already deployed in this session, either individually - // or as part of an earlier deploy-at-end batch. Re-entering (a second bound deploy - // execution, or a direct deploy:deploy invocation) must not publish it a second time. - getLog().info("Skipping deploy for " + project.getGroupId() + ":" + project.getArtifactId() + ":" - + project.getVersion() + ": it has already been deployed in this session"); - return; + synchronized (DEPLOY_AT_END_LOCK) { + if (getState(project) == State.DEPLOYED) { + // Terminal state: the project was already deployed in this session, either individually + // or as part of an earlier deploy-at-end batch. Re-entering (a second bound deploy + // execution, or a direct deploy:deploy invocation) must not publish it a second time. + getLog().info("Skipping deploy for " + project.getGroupId() + ":" + project.getArtifactId() + ":" + + project.getVersion() + ": it has already been deployed in this session"); + return; + } } if (Boolean.parseBoolean(skip) || ("releases".equals(skip) && !session.isVersionSnapshot(project.getVersion())) || ("snapshots".equals(skip) && session.isVersionSnapshot(project.getVersion()))) { getLog().info("Skipping artifact deployment"); - putState(State.SKIPPED); + synchronized (DEPLOY_AT_END_LOCK) { + putState(State.SKIPPED); + } } else { failIfOffline(); warnIfAffectedPackagingAndMaven(project.getPackaging().id()); @@ -203,20 +220,31 @@ public void execute() { getLog().info("Deploying deploy for " + project.getGroupId() + ":" + project.getArtifactId() + ":" + project.getVersion() + " at end"); deploy(createDeployerRequest()); - putState(State.DEPLOYED); + synchronized (DEPLOY_AT_END_LOCK) { + putState(State.DEPLOYED); + } } else { - // compute the request - putState(State.TO_BE_DEPLOYED); - putState(createDeployerRequest()); - if (!allProjectsMarked()) { - getLog().info("Deferring deploy for " + project.getGroupId() + ":" + project.getArtifactId() + ":" - + project.getVersion() + " at end"); + // compute the request outside the lock; only the state mark-and-check is serialized + ArtifactDeployerRequest request = createDeployerRequest(); + synchronized (DEPLOY_AT_END_LOCK) { + putState(State.TO_BE_DEPLOYED); + putState(request); + if (!allProjectsMarked()) { + getLog().info("Deferring deploy for " + project.getGroupId() + ":" + project.getArtifactId() + + ":" + project.getVersion() + " at end"); + } } } } - if (allProjectsMarked()) { - deployAllAtOnce(); + synchronized (DEPLOY_AT_END_LOCK) { + // check-then-act must be atomic: without the lock two -T threads can both observe + // allProjectsMarked() == true and both fire the batch. Holding the lock across + // deployAllAtOnce() is intentional - a concurrent second trigger waits, then finds + // every batched project already DEPLOYED and no-ops. + if (allProjectsMarked()) { + deployAllAtOnce(); + } } } From a5e9653b5e021d0870c06490cffff954cc59d3bc Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 31 Aug 2026 13:46:28 +0200 Subject: [PATCH 06/18] =?UTF-8?q?fix:=20f006=20(MEDIUM)=20=E2=80=94=20Veri?= =?UTF-8?q?fied-true=20doc=20fix=20plus=20visibility=20escalation,=20per?= =?UTF-8?q?=20the=20finding=20brief.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security audit finding f006 (MEDIUM). Verified-true doc fix plus visibility escalation, per the finding brief. The deployAtEnd javadoc now states the real semantics: batch fires when the last deploy-bound project reaches its deploy phase (trailing execution-less modules can still fail afterward), multi-repo/mixed-retry requests deploy sequentially with no rollback, and deployAtEnd=false modules cannot be recalled; the false 'none of the reactor projects is deployed' sentence and the incoherent '(experimental)' marker (on a default-on publish path) are removed. Partial-failure visibility: deployAllAtOnce now deploys the grouped requests in an explicit loop and, on failure after at least one group succeeded, logs an ERROR naming how many groups and which repository ids were already published ('remain published: there is no rollback') before rethrowing, so the failure is no longer attributed only to the wrong module. Co-Authored-By: Claude Opus 4.6 --- .../maven/plugins/deploy/DeployMojo.java | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java b/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java index 381675d8..3e7e9770 100644 --- a/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java +++ b/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java @@ -64,9 +64,24 @@ public class DeployMojo extends AbstractDeployMojo { private MojoExecution mojoExecution; /** - * Whether every project should be deployed during its own deploy-phase or at the end of the multimodule build. If - * set to {@code true} and the build fails, none of the reactor projects is deployed. - * (experimental) + * Whether every project should be deployed during its own deploy-phase or at the end of the multimodule build. + * When set to {@code true}, the deploy requests of all projects with a bound deploy execution are collected and + * executed together once the last such project has reached its deploy phase, which reduces the chance of + * publishing artifacts from a build that subsequently fails. + *

+ * This is not an atomic, all-or-nothing guarantee. In particular: + *

    + *
  • The batch fires when the last project with a deploy execution reaches its deploy phase. + * Reactor projects built after that point (for example trailing modules that skip or do not bind the + * deploy goal, such as integration-test aggregators) can still fail after all artifacts have + * been published.
  • + *
  • When the batch spans several repositories or retry configurations, the resulting requests are + * deployed sequentially: a failure part-way through leaves the repositories already deployed to + * published, with no rollback. The build log reports which repositories had already been deployed + * when this happens.
  • + *
  • Projects configured with {@code deployAtEnd=false} deploy immediately during their own deploy + * phase and cannot be recalled by a later build failure.
  • + *
* * @since 2.8 */ @@ -313,7 +328,23 @@ private void deployAllAtOnce() { } // Deploy if (!requests.isEmpty()) { - requests.forEach(this::deploy); + // Requests are deployed sequentially and there is no rollback: if one fails, make the + // partial-publication state explicit instead of only surfacing the failing module. + List deployedRepositoryIds = new ArrayList<>(); + for (ArtifactDeployerRequest request : requests) { + try { + deploy(request); + } catch (RuntimeException e) { + if (!deployedRepositoryIds.isEmpty()) { + getLog().error("Deploy-at-end batch failed after " + deployedRepositoryIds.size() + " of " + + requests.size() + " deploy request(s) had already completed. Artifacts already" + + " published to repository id(s) " + String.join(", ", deployedRepositoryIds) + + " remain published: there is no rollback."); + } + throw e; + } + deployedRepositoryIds.add(request.getRepository().getId()); + } } else { getLog().info("No actual deploy requests"); } From 6812306ef6b08deff6d3a236eee5a40f00913523 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 31 Aug 2026 13:56:49 +0200 Subject: [PATCH 07/18] fix: adapt deployAtEnd re-deploy test for cache optimization compatibility The deployAtEndBatchIsNotRedeployedOnReentry test needs a properly mocked MojoExecution with plugin model, because the upstream cache optimization (PROJECTS_WITH_DEPLOY_KEY) calls mojoExecution.getPlugin().getModel().getKey() in hasDeployExecution(). Co-Authored-By: Claude Opus 4.6 --- .../maven/plugins/deploy/DeployMojoTest.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java b/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java index 4709b08d..8f3aeae3 100644 --- a/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java +++ b/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java @@ -433,10 +433,26 @@ void loopbackHttpAltDeploymentRepositoryAccepted(DeployMojo mojo) throws Excepti @Test @InjectMojo(goal = "deploy") void deployAtEndBatchIsNotRedeployedOnReentry(DeployMojo mojo) throws Exception { + // Set up mojoExecution with a proper plugin model so hasDeployExecution() can look up + // the deploy plugin key. This is needed because the cache optimization (PROJECTS_WITH_DEPLOY_KEY) + // calls mojoExecution.getPlugin().getModel().getKey() to find the current plugin in each project's + // build plugins map. + org.apache.maven.api.model.Plugin pluginModel = org.apache.maven.api.model.Plugin.newBuilder() + .groupId("org.apache.maven.plugins") + .artifactId("maven-deploy-plugin") + .build(); + org.apache.maven.api.Plugin mojoPlugin = org.mockito.Mockito.mock(org.apache.maven.api.Plugin.class); + org.apache.maven.api.MojoExecution mojoExec = + org.mockito.Mockito.mock(org.apache.maven.api.MojoExecution.class); + org.mockito.Mockito.doReturn(mojoPlugin).when(mojoExec).getPlugin(); + org.mockito.Mockito.doReturn(pluginModel).when(mojoPlugin).getModel(); + setVariableValueToObject(mojo, "mojoExecution", mojoExec); + Project project = (Project) getVariableValueFromObject(mojo, "project"); artifactManager.setPath( project.getMainArtifact().get(), Paths.get(getBasedir(), "target/test-classes/unit/maven-deploy-test-1.0-SNAPSHOT.jar")); + // give the session a persistent plugin context and a real reactor project list Map> contexts = new HashMap<>(); when(session.getPluginContext(any(Project.class))) From 779362f8b83b73796f942962a5a1d0c72f2390f1 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 31 Aug 2026 15:44:42 +0200 Subject: [PATCH 08/18] fix: allow empty classifiers in deploy-file extras (IT regression) The classifier validator from f003 wrongly rejected empty classifier entries in the classifiers CSV (e.g. "classifiers=,src,"). Empty means "no classifier" and is valid Maven convention used by the 3rd-party-{jar,pom}-with-extras ITs. isValidClassifier("") already returns true; the redundant isEmpty() pre-check was the bug. Co-Authored-By: Claude Opus 4.6 --- .../java/org/apache/maven/plugins/deploy/DeployFileMojo.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java b/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java index 0ea359bc..3a74b8a9 100644 --- a/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java +++ b/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java @@ -388,9 +388,9 @@ public void execute() throws MojoException { throw new MojoException("The 'types' entry '" + type + "' is not valid:" + " uses invalid characters or is empty."); } - if (classifierEntry.isEmpty() || !isValidClassifier(classifierEntry)) { + if (!isValidClassifier(classifierEntry)) { throw new MojoException("The 'classifiers' entry '" + classifierEntry + "' is not valid:" - + " uses invalid characters or is empty."); + + " uses invalid characters."); } ProducedArtifact deployable = session.createProducedArtifact( From fdeb5959fb4ceed9cda25145b0e7634546a03fab Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 31 Aug 2026 13:57:51 +0200 Subject: [PATCH 09/18] =?UTF-8?q?fix:=20f007=20(LOW)=20=E2=80=94=20Shared?= =?UTF-8?q?=20fail-closed=20parser=20AbstractDeployMojo.parseSkipMode(valu?= =?UTF-8?q?e,=20parame?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../plugins/deploy/AbstractDeployMojo.java | 34 ++++++++++++++++ .../maven/plugins/deploy/DeployFileMojo.java | 20 ++++++++-- .../maven/plugins/deploy/DeployMojo.java | 10 +++-- .../maven/plugins/deploy/DeployMojoTest.java | 39 +++++++++++++++++++ 4 files changed, 95 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/apache/maven/plugins/deploy/AbstractDeployMojo.java b/src/main/java/org/apache/maven/plugins/deploy/AbstractDeployMojo.java index e9039cbd..93716e8e 100644 --- a/src/main/java/org/apache/maven/plugins/deploy/AbstractDeployMojo.java +++ b/src/main/java/org/apache/maven/plugins/deploy/AbstractDeployMojo.java @@ -107,6 +107,40 @@ protected void warnIfAffectedPackagingAndMaven(String packaging) { } } + /** + * Recognized values of the {@code skip} tri-state shared by both mojos. + */ + enum SkipMode { + NONE, + ALL, + RELEASES, + SNAPSHOTS + } + + /** + * Parses the {@code skip} tri-state fail-closed: {@code skip} is a publish-suppression + * control, so an unrecognized value (a typo such as {@code ture} or {@code release}) must fail + * the build instead of silently publishing. Values are matched case-insensitively. + */ + static SkipMode parseSkipMode(String value, String parameterName) throws MojoException { + if (value == null || value.isEmpty() || "false".equalsIgnoreCase(value)) { + return SkipMode.NONE; + } + if ("true".equalsIgnoreCase(value)) { + return SkipMode.ALL; + } + if ("releases".equalsIgnoreCase(value)) { + return SkipMode.RELEASES; + } + if ("snapshots".equalsIgnoreCase(value)) { + return SkipMode.SNAPSHOTS; + } + throw new MojoException("Unrecognized value '" + value + "' for " + parameterName + + ": supported values are true, false, releases and snapshots." + + " Refusing to deploy on an unrecognized value: a typo in a publish-suppression" + + " control must not silently publish."); + } + /** * Creates resolver {@link RemoteRepository} equipped with needed whistles and bells. */ diff --git a/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java b/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java index 3a74b8a9..4b3a5b52 100644 --- a/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java +++ b/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java @@ -183,8 +183,12 @@ public class DeployFileMojo extends AbstractDeployMojo { *
  • true: will skip as usual
  • *
  • releases: will skip if current version of the project is a release
  • *
  • snapshots: will skip if current version of the project is a snapshot
  • - *
  • any other values will be considered as false
  • + *
  • values are matched case-insensitively; any other value fails the build (fail-closed: + * a typo in a publish-suppression control must not silently publish)
  • * + * The releases/snapshots variants are evaluated after the artifact + * coordinates are known, so a version supplied only via pomFile (or the jar's + * embedded POM) is classified correctly. * @since 3.1.0 */ @Parameter(property = "maven.deploy.file.skip", defaultValue = "false") @@ -243,9 +247,8 @@ private Path readingPomFromJarFile() { @SuppressWarnings("checkstyle:MethodLength") public void execute() throws MojoException { - if (Boolean.parseBoolean(skip) - || ("releases".equals(skip) && !session.isVersionSnapshot(version)) - || ("snapshots".equals(skip) && session.isVersionSnapshot(version))) { + SkipMode skipMode = parseSkipMode(skip, "maven.deploy.file.skip"); + if (skipMode == SkipMode.ALL) { getLog().info("Skipping artifact deployment"); return; } @@ -279,6 +282,15 @@ public void execute() throws MojoException { + "'version' and 'packaging' are required."); } + // the releases/snapshots skip variants classify the version, so they are evaluated only + // after the version is known - including a version supplied via pomFile or the jar's + // embedded POM (previously they classified a possibly-null version) + if ((skipMode == SkipMode.RELEASES && !session.isVersionSnapshot(version)) + || (skipMode == SkipMode.SNAPSHOTS && session.isVersionSnapshot(version))) { + getLog().info("Skipping artifact deployment"); + return; + } + if (!isValidId(groupId) || !isValidId(artifactId) || !isValidVersion(version)) { throw new MojoException("The artifact information is not valid: uses invalid characters."); } diff --git a/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java b/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java index 3e7e9770..67e4f2d4 100644 --- a/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java +++ b/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java @@ -147,7 +147,8 @@ public class DeployMojo extends AbstractDeployMojo { *
  • true: will skip as usual
  • *
  • releases: will skip if current version of the project is a release
  • *
  • snapshots: will skip if current version of the project is a snapshot
  • - *
  • any other values will be considered as false
  • + *
  • values are matched case-insensitively; any other value fails the build (fail-closed: + * a typo in a publish-suppression control must not silently publish)
  • * * @since 2.4 */ @@ -220,9 +221,10 @@ public void execute() { return; } } - if (Boolean.parseBoolean(skip) - || ("releases".equals(skip) && !session.isVersionSnapshot(project.getVersion())) - || ("snapshots".equals(skip) && session.isVersionSnapshot(project.getVersion()))) { + SkipMode skipMode = parseSkipMode(skip, "maven.deploy.skip"); + if (skipMode == SkipMode.ALL + || (skipMode == SkipMode.RELEASES && !session.isVersionSnapshot(project.getVersion())) + || (skipMode == SkipMode.SNAPSHOTS && session.isVersionSnapshot(project.getVersion()))) { getLog().info("Skipping artifact deployment"); synchronized (DEPLOY_AT_END_LOCK) { putState(State.SKIPPED); diff --git a/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java b/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java index 8f3aeae3..c9c50030 100644 --- a/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java +++ b/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java @@ -471,6 +471,45 @@ void deployAtEndBatchIsNotRedeployedOnReentry(DeployMojo mojo) throws Exception assertEquals(1, captor.getAllValues().size(), "re-entry must not re-deploy the batch"); } + @Test + @InjectMojo(goal = "deploy") + @MojoParameter(name = "deployAtEnd", value = "false") + void skipTypoFailsClosed(DeployMojo mojo) throws Exception { + setVariableValueToObject(mojo, "skip", "ture"); + + MojoException e = assertThrows(MojoException.class, mojo::execute); + assertTrue(e.getMessage().contains("Unrecognized value 'ture'"), e.getMessage()); + } + + @Test + @InjectMojo(goal = "deploy") + @MojoParameter(name = "deployAtEnd", value = "false") + void skipValuesAreCaseInsensitive(DeployMojo mojo) throws Exception { + Project project = (Project) getVariableValueFromObject(mojo, "project"); + artifactManager.setPath( + project.getMainArtifact().get(), + Paths.get(getBasedir(), "target/test-classes/unit/maven-deploy-test-1.0-SNAPSHOT.jar")); + // project version is 1.0-SNAPSHOT: skip=SNAPSHOTS (any case) must skip + when(session.isVersionSnapshot("1.0-SNAPSHOT")).thenReturn(true); + setVariableValueToObject(mojo, "skip", "SNAPSHOTS"); + + assertNull(execute(mojo)); + } + + @Test + @InjectMojo(goal = "deploy") + @MojoParameter(name = "deployAtEnd", value = "false") + void skipReleasesDoesNotSkipSnapshotProject(DeployMojo mojo) throws Exception { + Project project = (Project) getVariableValueFromObject(mojo, "project"); + artifactManager.setPath( + project.getMainArtifact().get(), + Paths.get(getBasedir(), "target/test-classes/unit/maven-deploy-test-1.0-SNAPSHOT.jar")); + when(session.isVersionSnapshot("1.0-SNAPSHOT")).thenReturn(true); + setVariableValueToObject(mojo, "skip", "Releases"); + + assertNotNull(execute(mojo)); + } + private ArtifactDeployerRequest execute(DeployMojo mojo) { ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(ArtifactDeployerRequest.class); doNothing().when(artifactDeployer).deploy(requestCaptor.capture()); From baf3a028bdba33ddc358606e5aa5777d0f8e26f7 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 31 Aug 2026 13:57:51 +0200 Subject: [PATCH 10/18] =?UTF-8?q?fix:=20f008=20(LOW)=20=E2=80=94=20The=20d?= =?UTF-8?q?m=20selection=20now=20distinguishes=20'no=20snapshotRepository?= =?UTF-8?q?=20declared'=20(doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../maven/plugins/deploy/DeployMojo.java | 16 ++++++++++++--- .../maven/plugins/deploy/DeployMojoTest.java | 20 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java b/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java index 67e4f2d4..b87a1bf8 100644 --- a/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java +++ b/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java @@ -487,10 +487,10 @@ RemoteRepository getDeploymentRepository(boolean isSnapshot) throws MojoExceptio if (repo == null) { DistributionManagement dm = project.getModel().getDistributionManagement(); if (dm != null) { - if (isSnapshot - && dm.getSnapshotRepository() != null + boolean snapshotRepositoryUsable = dm.getSnapshotRepository() != null && isNotEmpty(dm.getSnapshotRepository().getId()) - && isNotEmpty(dm.getSnapshotRepository().getUrl())) { + && isNotEmpty(dm.getSnapshotRepository().getUrl()); + if (isSnapshot && snapshotRepositoryUsable) { validateTransportSecurity( dm.getSnapshotRepository().getId(), dm.getSnapshotRepository().getUrl()); @@ -498,6 +498,16 @@ && isNotEmpty(dm.getSnapshotRepository().getUrl())) { } else if (dm.getRepository() != null && isNotEmpty(dm.getRepository().getId()) && isNotEmpty(dm.getRepository().getUrl())) { + if (isSnapshot && dm.getSnapshotRepository() != null) { + // a declared-but-unusable snapshotRepository is a config error; falling + // back silently would route snapshots to a repository with a different + // audience, retention policy and credentials + getLog().warn("distributionManagement declares a whose id or url is" + + " empty; falling back to the release '" + + dm.getRepository().getId() + "' (" + + dm.getRepository().getUrl() + + ") for this snapshot deployment"); + } validateTransportSecurity( dm.getRepository().getId(), dm.getRepository().getUrl()); repo = session.createRemoteRepository(dm.getRepository()); diff --git a/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java b/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java index c9c50030..cda3394f 100644 --- a/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java +++ b/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java @@ -510,6 +510,26 @@ void skipReleasesDoesNotSkipSnapshotProject(DeployMojo mojo) throws Exception { assertNotNull(execute(mojo)); } + @Test + @InjectMojo(goal = "deploy") + void snapshotDeployFallsBackToReleaseRepositoryWhenSnapshotRepositoryUnusable(DeployMojo mojo) throws Exception { + ProjectStub project = (ProjectStub) getVariableValueFromObject(mojo, "project"); + project.setModel(project.getModel() + .withDistributionManagement(org.apache.maven.api.model.DistributionManagement.newBuilder() + .snapshotRepository(org.apache.maven.api.model.DeploymentRepository.newBuilder() + .id("snapshots") + .url("") // declared but unusable, e.g. a property interpolating empty + .build()) + .repository(org.apache.maven.api.model.DeploymentRepository.newBuilder() + .id("releases") + .url("https://releases.example/repo") + .build()) + .build())); + + RemoteRepository repository = mojo.getDeploymentRepository(true); + assertEquals("releases", repository.getId()); + } + private ArtifactDeployerRequest execute(DeployMojo mojo) { ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(ArtifactDeployerRequest.class); doNothing().when(artifactDeployer).deploy(requestCaptor.capture()); From a710c2f65c189b69427eff7921f0737e190cff07 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 31 Aug 2026 13:57:51 +0200 Subject: [PATCH 11/18] =?UTF-8?q?fix:=20f009=20(LOW)=20=E2=80=94=20Warn-ba?= =?UTF-8?q?sed=20client-side=20guard,=20honest=20about=20the=20static=20li?= =?UTF-8?q?mit=20the=20audit=20re?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../maven/plugins/deploy/DeployMojo.java | 23 +++++++++++++++++++ .../maven/plugins/deploy/DeployMojoTest.java | 20 ++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java b/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java index b87a1bf8..66784d50 100644 --- a/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java +++ b/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java @@ -112,6 +112,10 @@ public class DeployMojo extends AbstractDeployMojo { * When the id matches a settings.xml server entry and the URL differs from every URL this build * associates with that id, the deployment is refused unless * -Dmaven.deploy.allowCredentialReuse=true is given on the command line. + *

    + * Policy note: unlike a repository declared in distributionManagement, an alternative + * repository is built from just id::url and therefore carries API-default release/snapshot + * policies and serves both artifact kinds; release/snapshot acceptance is enforced by the server only. */ @Parameter(property = "altDeploymentRepository") private String altDeploymentRepository; @@ -494,6 +498,7 @@ && isNotEmpty(dm.getSnapshotRepository().getId()) validateTransportSecurity( dm.getSnapshotRepository().getId(), dm.getSnapshotRepository().getUrl()); + warnIfPolicyMismatch(dm.getSnapshotRepository(), isSnapshot); repo = session.createRemoteRepository(dm.getSnapshotRepository()); } else if (dm.getRepository() != null && isNotEmpty(dm.getRepository().getId()) @@ -510,6 +515,7 @@ && isNotEmpty(dm.getRepository().getUrl())) { } validateTransportSecurity( dm.getRepository().getId(), dm.getRepository().getUrl()); + warnIfPolicyMismatch(dm.getRepository(), isSnapshot); repo = session.createRemoteRepository(dm.getRepository()); } } @@ -587,6 +593,23 @@ && isNotEmpty(dm.getSnapshotRepository().getUrl())) { return urls; } + /** + * Client-side release/snapshot policy sanity check: warns when the artifact kind being deployed + * is explicitly disabled on the selected repository's declared policy. Enforcement stays + * server-side (the resolver does not consult target policies when deploying); this only makes + * the mismatch visible before the upload starts. + */ + private void warnIfPolicyMismatch(org.apache.maven.api.model.DeploymentRepository repository, boolean isSnapshot) { + org.apache.maven.api.model.RepositoryPolicy policy = + isSnapshot ? repository.getSnapshots() : repository.getReleases(); + if (policy != null && !policy.isEnabled()) { + getLog().warn("Deployment repository '" + repository.getId() + "' declares <" + + (isSnapshot ? "snapshots" : "releases") + ">false, but a " + + (isSnapshot ? "snapshot" : "release") + + " artifact is being deployed to it; the server is expected to reject this upload"); + } + } + private boolean isValidPath(Artifact a) { return getArtifactManager().getPath(a).filter(Files::isRegularFile).isPresent(); } diff --git a/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java b/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java index cda3394f..3b2b6ece 100644 --- a/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java +++ b/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java @@ -530,6 +530,26 @@ void snapshotDeployFallsBackToReleaseRepositoryWhenSnapshotRepositoryUnusable(De assertEquals("releases", repository.getId()); } + @Test + @InjectMojo(goal = "deploy") + void policyMismatchWarnsButDoesNotFail(DeployMojo mojo) throws Exception { + ProjectStub project = (ProjectStub) getVariableValueFromObject(mojo, "project"); + project.setModel(project.getModel() + .withDistributionManagement(org.apache.maven.api.model.DistributionManagement.newBuilder() + .repository(org.apache.maven.api.model.DeploymentRepository.newBuilder() + .id("releases") + .url("https://releases.example/repo") + .releases(org.apache.maven.api.model.RepositoryPolicy.newBuilder() + .enabled("false") + .build()) + .build()) + .build())); + + // enforcement stays server-side: the mismatch is warned about, not refused + RemoteRepository repository = mojo.getDeploymentRepository(false); + assertEquals("releases", repository.getId()); + } + private ArtifactDeployerRequest execute(DeployMojo mojo) { ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(ArtifactDeployerRequest.class); doNothing().when(artifactDeployer).deploy(requestCaptor.capture()); From 8c7c7e11786cd3a1a57339548d5df8a61635e477 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 31 Aug 2026 13:57:52 +0200 Subject: [PATCH 12/18] =?UTF-8?q?fix:=20f010=20(LOW)=20=E2=80=94=20New=20A?= =?UTF-8?q?bstractDeployMojo.redactUrlUserInfo()=20masks=20scheme://userin?= =?UTF-8?q?fo@=20as=20s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../maven/plugins/deploy/AbstractDeployMojo.java | 13 +++++++++++++ .../apache/maven/plugins/deploy/DeployFileMojo.java | 4 +++- .../org/apache/maven/plugins/deploy/DeployMojo.java | 5 +++-- .../plugins/deploy/DeployFileMojoUnitTest.java | 13 +++++++++++++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/apache/maven/plugins/deploy/AbstractDeployMojo.java b/src/main/java/org/apache/maven/plugins/deploy/AbstractDeployMojo.java index 93716e8e..6e155dbb 100644 --- a/src/main/java/org/apache/maven/plugins/deploy/AbstractDeployMojo.java +++ b/src/main/java/org/apache/maven/plugins/deploy/AbstractDeployMojo.java @@ -454,6 +454,19 @@ private boolean isCredentialReuseAllowed() { return userProperties != null && Boolean.parseBoolean(userProperties.get(ALLOW_CREDENTIAL_REUSE_PROPERTY)); } + /** + * Masks URL-embedded userinfo ({@code scheme://user:token@host/...}) before a repository + * string is logged: build logs are routinely archived and shared, and the deploy log line + * must be safe to keep while remaining useful as the audit signal for where artifacts and + * credentials were sent. + */ + static String redactUrlUserInfo(String value) { + if (value == null) { + return null; + } + return value.replaceAll("://[^/@\\s]+@", "://***@"); + } + static String normalizeRepositoryUrl(String url) { String normalized = url.trim(); while (normalized.endsWith("/")) { diff --git a/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java b/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java index 4b3a5b52..85c3f47d 100644 --- a/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java +++ b/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java @@ -438,7 +438,9 @@ public void execute() throws MojoException { .retryFailedDeploymentCount(Math.max(1, Math.min(10, getRetryFailedDeploymentCount()))) .build(); - getLog().info("Deploying artifacts " + deployables + " to repository " + deploymentRepository); + getLog().info("Deploying artifacts " + deployables + " to repository " + + deploymentRepository.getId() + " (" + + redactUrlUserInfo(deploymentRepository.getUrl()) + ")"); ArtifactDeployer artifactDeployer = session.getService(ArtifactDeployer.class); artifactDeployer.deploy(deployRequest); } catch (ArtifactDeployerException e) { diff --git a/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java b/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java index 66784d50..4938c1bf 100644 --- a/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java +++ b/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java @@ -365,7 +365,8 @@ private void deployAllAtOnce() { private void deploy(ArtifactDeployerRequest request) { try { getLog().info("Deploying artifacts " + request.getArtifacts().toString() + " to repository " - + request.getRepository()); + + request.getRepository().getId() + " (" + + redactUrlUserInfo(request.getRepository().getUrl()) + ")"); getArtifactDeployer().deploy(request); } catch (MojoException e) { throw e; @@ -451,7 +452,7 @@ RemoteRepository getDeploymentRepository(boolean isSnapshot) throws MojoExceptio } if (altDeploymentRepo != null) { - getLog().info("Using alternate deployment repository " + altDeploymentRepo); + getLog().info("Using alternate deployment repository " + redactUrlUserInfo(altDeploymentRepo)); Matcher matcher = ALT_LEGACY_REPO_SYNTAX_PATTERN.matcher(altDeploymentRepo); diff --git a/src/test/java/org/apache/maven/plugins/deploy/DeployFileMojoUnitTest.java b/src/test/java/org/apache/maven/plugins/deploy/DeployFileMojoUnitTest.java index 21f41759..6fa96a75 100644 --- a/src/test/java/org/apache/maven/plugins/deploy/DeployFileMojoUnitTest.java +++ b/src/test/java/org/apache/maven/plugins/deploy/DeployFileMojoUnitTest.java @@ -155,6 +155,19 @@ void classifierAndTypeValidationRejectsLayoutTraversal() { assertTrue(AbstractDeployMojo.isValidTypeOrExtension("maven-plugin")); } + @Test + void urlUserInfoIsRedactedForLogging() { + assertEquals( + "https://***@repo.example/releases", + AbstractDeployMojo.redactUrlUserInfo("https://user:s3cr3t@repo.example/releases")); + assertEquals( + "my-repo::https://***@repo.example/releases", + AbstractDeployMojo.redactUrlUserInfo("my-repo::https://ci-bot:tok3n@repo.example/releases")); + assertEquals( + "https://repo.example/releases", AbstractDeployMojo.redactUrlUserInfo("https://repo.example/releases")); + assertEquals("file:///tmp/repo", AbstractDeployMojo.redactUrlUserInfo("file:///tmp/repo")); + } + private void setMojoModel( MockDeployFileMojo mojo, String group, String artifact, String version, String packaging, Parent parent) { mojo.model = Model.newBuilder() From be183fedb5b819952130be5926de8c073e2a3f6a Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 31 Aug 2026 13:57:52 +0200 Subject: [PATCH 13/18] =?UTF-8?q?fix:=20f011=20(LOW)=20=E2=80=94=20Opt-in?= =?UTF-8?q?=20containment=20knob,=20per=20the=20triage=20recommendation=20?= =?UTF-8?q?(pipeline=20variabl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../maven/plugins/deploy/DeployFileMojo.java | 54 +++++++++++++++++++ .../deploy/DeployFileMojoUnitTest.java | 13 +++++ 2 files changed, 67 insertions(+) diff --git a/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java b/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java index 85c3f47d..e55c43f0 100644 --- a/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java +++ b/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java @@ -194,6 +194,21 @@ public class DeployFileMojo extends AbstractDeployMojo { @Parameter(property = "maven.deploy.file.skip", defaultValue = "false") private String skip = Boolean.FALSE.toString(); + /** + * Optional containment directory for all artifact-content paths ({@code file}, {@code files}, + * {@code sources}, {@code javadoc}, {@code pomFile}): when set, each of those paths must resolve + * inside this directory (symlinks are resolved before the comparison) or the build fails. + *

    + * Intended for CI/automation that fills deploy-file parameters from pipeline variables: without + * containment, any file readable by the build (for example {@code ~/.m2/settings.xml} passed as a + * side artifact) can be published to the deployment URL in one invocation. Disabled (no + * containment) by default; point it at the project or workspace directory in automated pipelines. + * + * @since 4.0.0 + */ + @Parameter(property = "maven.deploy.file.containedIn") + private Path containedIn; + void initProperties() throws MojoException { Path deployedPom; if (pomFile != null) { @@ -259,6 +274,13 @@ public void execute() throws MojoException { throw new MojoException(message); } + // containment applies to the operator-supplied paths, before any of them is read; + // the temporary POM later extracted from the (already contained) jar is exempt + checkContained(file, "file"); + checkContained(pomFile, "pomFile"); + checkContained(sources, "sources"); + checkContained(javadoc, "javadoc"); + initProperties(); validateCredentialBinding(repositoryId, url.replace(File.separator, "/")); @@ -392,6 +414,7 @@ public void execute() throws MojoException { file = Paths.get(files.substring(fi, nfi)); } if (Files.isRegularFile(file)) { + checkContained(file, "files"); String extension = getExtension(file); String type = types.substring(ti, nti).trim(); String classifierEntry = classifiers.substring(ci, nci).trim(); @@ -459,6 +482,37 @@ public void execute() throws MojoException { } } + /** + * Enforces the optional {@link #containedIn} containment directory for an artifact-content path. + */ + private void checkContained(Path path, String parameterName) throws MojoException { + if (containedIn == null || path == null) { + return; + } + if (!isContainedIn(path, containedIn)) { + throw new MojoException("Parameter '" + parameterName + "' resolves to " + + path.toAbsolutePath().normalize() + ", which is outside the containment directory " + + containedIn.toAbsolutePath().normalize() + " configured with maven.deploy.file.containedIn"); + } + } + + /** + * Returns {@code true} when {@code path} resolves inside {@code root}, resolving symlinks where + * the paths exist so a link pointing outside the containment directory does not pass. + */ + static boolean isContainedIn(Path path, Path root) { + return realOrNormalized(path).startsWith(realOrNormalized(root)); + } + + private static Path realOrNormalized(Path path) { + Path absolute = path.toAbsolutePath().normalize(); + try { + return absolute.toRealPath(); + } catch (IOException e) { + return absolute; + } + } + /** * Gets the path of the specified artifact within the local repository. Note that the returned path need not exist * (yet). diff --git a/src/test/java/org/apache/maven/plugins/deploy/DeployFileMojoUnitTest.java b/src/test/java/org/apache/maven/plugins/deploy/DeployFileMojoUnitTest.java index 6fa96a75..3c95dd14 100644 --- a/src/test/java/org/apache/maven/plugins/deploy/DeployFileMojoUnitTest.java +++ b/src/test/java/org/apache/maven/plugins/deploy/DeployFileMojoUnitTest.java @@ -18,6 +18,8 @@ */ package org.apache.maven.plugins.deploy; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -168,6 +170,17 @@ void urlUserInfoIsRedactedForLogging() { assertEquals("file:///tmp/repo", AbstractDeployMojo.redactUrlUserInfo("file:///tmp/repo")); } + @Test + void containmentDirectoryIsEnforced() throws IOException { + Path root = Files.createTempDirectory("deploy-file-containment"); + Path inside = Files.createFile(root.resolve("artifact.jar")); + + assertTrue(DeployFileMojo.isContainedIn(inside, root)); + assertTrue(DeployFileMojo.isContainedIn(root.resolve("sub/other.jar"), root)); + assertFalse(DeployFileMojo.isContainedIn(root.resolve("../escaped.jar"), root)); + assertFalse(DeployFileMojo.isContainedIn(Paths.get("/etc/passwd"), root)); + } + private void setMojoModel( MockDeployFileMojo mojo, String group, String artifact, String version, String packaging, Parent parent) { mojo.model = Model.newBuilder() From b8e8bcb3dba28d5712563c41642c2cad7069dbda Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 31 Aug 2026 13:57:52 +0200 Subject: [PATCH 14/18] =?UTF-8?q?fix:=20f012=20(LOW)=20=E2=80=94=20Two=20t?= =?UTF-8?q?argeted=20rejections=20rather=20than=20a=20pattern-order=20swap?= =?UTF-8?q?:=20reordering=20the?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../maven/plugins/deploy/DeployMojo.java | 26 +++++++++++++++++++ .../maven/plugins/deploy/DeployMojoTest.java | 19 ++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java b/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java index 4938c1bf..8c442ca6 100644 --- a/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java +++ b/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java @@ -462,8 +462,19 @@ RemoteRepository getDeploymentRepository(boolean isSnapshot) throws MojoExceptio String url = matcher.group(3).trim(); if ("default".equals(layout)) { + if (url.contains("::")) { + // "a::default::b::c" would otherwise be accepted with url "b::c": refuse + // instead of guessing which of the two possible parses was intended + throw new MojoException( + altDeploymentRepo, + "Ambiguous syntax for alternative repository.", + "Ambiguous alternative repository: the value parses as legacy \"" + id + "::" + layout + + "::" + url + "\" but its URL part still contains \"::\"." + + " Use \"id::url\" with a URL that does not contain \"::\"."); + } getLog().warn("Using legacy syntax for alternative repository. " + "Use \"" + id + "::" + url + "\" instead."); + requireNonEmptyIdAndUrl(altDeploymentRepo, id, url); repo = createAltDeploymentRepository(id, url); } else { throw new MojoException( @@ -484,6 +495,7 @@ RemoteRepository getDeploymentRepository(boolean isSnapshot) throws MojoExceptio String id = matcher.group(1).trim(); String url = matcher.group(2).trim(); + requireNonEmptyIdAndUrl(altDeploymentRepo, id, url); repo = createAltDeploymentRepository(id, url); } } @@ -532,6 +544,20 @@ && isNotEmpty(dm.getRepository().getUrl())) { return repo; } + /** + * An alternative repository whose id or url trims to empty cannot bind credentials or be + * deployed to meaningfully; refuse instead of continuing with a blank id (whose credential + * lookup would fail server-side) or a blank URL. + */ + private static void requireNonEmptyIdAndUrl(String altDeploymentRepo, String id, String url) { + if (id.isEmpty() || url.isEmpty()) { + throw new MojoException( + altDeploymentRepo, + "Invalid syntax for repository.", + "Invalid syntax for alternative repository: id and url must be non-empty. Use \"id::url\"."); + } + } + /** * Creates the repository for an alternative deployment target: warns when it overrides the * project's declared {@code distributionManagement} (naming the server id whose settings.xml diff --git a/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java b/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java index 3b2b6ece..58175e66 100644 --- a/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java +++ b/src/test/java/org/apache/maven/plugins/deploy/DeployMojoTest.java @@ -550,6 +550,25 @@ void policyMismatchWarnsButDoesNotFail(DeployMojo mojo) throws Exception { assertEquals("releases", repository.getId()); } + @Test + @InjectMojo(goal = "deploy") + void emptyIdAltDeploymentRepositoryRefused(DeployMojo mojo) throws Exception { + setVariableValueToObject(mojo, "altDeploymentRepository", " ::https://repo.example/releases"); + + MojoException e = assertThrows(MojoException.class, () -> mojo.getDeploymentRepository(false)); + assertEquals("Invalid syntax for repository.", e.getMessage()); + assertTrue(e.getLongMessage().contains("non-empty"), e.getLongMessage()); + } + + @Test + @InjectMojo(goal = "deploy") + void ambiguousLegacyAltDeploymentRepositoryRefused(DeployMojo mojo) throws Exception { + setVariableValueToObject(mojo, "altDeploymentRepository", "a::default::b::c"); + + MojoException e = assertThrows(MojoException.class, () -> mojo.getDeploymentRepository(false)); + assertEquals("Ambiguous syntax for alternative repository.", e.getMessage()); + } + private ArtifactDeployerRequest execute(DeployMojo mojo) { ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(ArtifactDeployerRequest.class); doNothing().when(artifactDeployer).deploy(requestCaptor.capture()); From 224ec5b471f80385d43f0d912a5c96e739d48c18 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Tue, 1 Sep 2026 13:01:00 +0200 Subject: [PATCH 15/18] fix: update deployfile-release-skip IT for version-aware skip ordering The f007 patch intentionally moved the releases/snapshots skip check after version resolution so that a version supplied via the jar's embedded POM is classified correctly. This means the jar IS inspected before the skip decision, which is expected. Update the IT to assert what matters (the deployment was skipped) instead of an implementation detail (whether the jar was opened). Co-Authored-By: Claude Opus 4.6 --- src/it/deployfile-release-skip/verify.groovy | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/it/deployfile-release-skip/verify.groovy b/src/it/deployfile-release-skip/verify.groovy index c476e408..69e17c01 100644 --- a/src/it/deployfile-release-skip/verify.groovy +++ b/src/it/deployfile-release-skip/verify.groovy @@ -21,4 +21,6 @@ assert !new File(basedir, "target/repo/org/apache/maven/plugins/deploy/its/deplo File buildLog = new File(basedir, 'build.log') assert buildLog.exists() -assert !buildLog.text.contains("[DEBUG] Using META-INF/maven/org.apache.maven.plugins.deploy.its/deployfile-release-skip/pom.xml as pomFile") +// The jar IS inspected (to resolve the version for the releases/snapshots classification), +// but the deployment is still skipped — that is the assertion that matters. +assert buildLog.text.contains("Skipping artifact deployment") From 1802dce8d9c376c25cc5f00a2d302307a3b81574 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Tue, 1 Sep 2026 16:40:46 +0200 Subject: [PATCH 16/18] fix: update deployfile-snapshot-skip IT for version-aware skip ordering Same fix as deployfile-release-skip: the jar is inspected to resolve the version before the snapshots skip check, which is the intended behavior of the version-aware skip reordering (f007). Co-Authored-By: Claude Opus 4.6 --- src/it/deployfile-snapshot-skip/verify.groovy | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/it/deployfile-snapshot-skip/verify.groovy b/src/it/deployfile-snapshot-skip/verify.groovy index ab46c590..44acc9e7 100644 --- a/src/it/deployfile-snapshot-skip/verify.groovy +++ b/src/it/deployfile-snapshot-skip/verify.groovy @@ -21,4 +21,6 @@ assert !new File(basedir, "target/repo/org/apache/maven/plugins/deploy/its/deplo File buildLog = new File(basedir, 'build.log') assert buildLog.exists() -assert !buildLog.text.contains("[DEBUG] Using META-INF/maven/org.apache.maven.plugins.deploy.its/deployfile-snapshot-skip/pom.xml as pomFile") +// The jar IS inspected (to resolve the version for the releases/snapshots classification), +// but the deployment is still skipped — that is the assertion that matters. +assert buildLog.text.contains("Skipping artifact deployment") From b3ad750152ad6fb8c87c07f241d07741639cdf00 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Tue, 1 Sep 2026 20:54:18 +0200 Subject: [PATCH 17/18] fix: resolve symlinks in isContainedIn for non-existent paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS, /var is a symlink to /private/var. When a non-existent path (e.g. an artifact that hasn't been created yet) is checked against an existing containment root, toRealPath() fails for the child but succeeds for the root — giving /var/... vs /private/var/..., which breaks the startsWith check. Fix realOrNormalized() to walk up to the closest existing ancestor, resolve its real path, then re-append the non-existent suffix. Co-Authored-By: Claude Opus 4.6 --- .../apache/maven/plugins/deploy/DeployFileMojo.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java b/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java index e55c43f0..f9cdfda0 100644 --- a/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java +++ b/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java @@ -509,6 +509,17 @@ private static Path realOrNormalized(Path path) { try { return absolute.toRealPath(); } catch (IOException e) { + // Path does not exist — resolve the closest existing ancestor so that platform + // symlinks are honoured (e.g. macOS /var → /private/var), then re-append the + // non-existent suffix. + Path parent = absolute.getParent(); + while (parent != null) { + try { + return parent.toRealPath().resolve(parent.relativize(absolute)); + } catch (IOException ignored) { + parent = parent.getParent(); + } + } return absolute; } } From ba5507ac9ffc72327dd75b777b90a8a458abf514 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Sun, 6 Sep 2026 17:57:20 +0200 Subject: [PATCH 18/18] =?UTF-8?q?Security=20audit:=20LOW/INFO=20docs=20and?= =?UTF-8?q?=20minor=20fixes=20=E2=80=94=20docs=20refresh,=20jar=20robustne?= =?UTF-8?q?ss,=20guard,=20log=20(#700)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: f013 (LOW) — Verified-true doc fixes (each claim checked against src/main before writ Co-Authored-By: Claude Opus 4.6 * fix: f014 (LOW) — All four fixed in readingPomFromJarFile/initProperties/finally: (1) the Co-Authored-By: Claude Opus 4.6 * fix: f015 (LOW) — The guard now compares locations: isSameLocation() resolves both sides t Co-Authored-By: Claude Opus 4.6 * fix: f016 (INFO) — The immediate branch now logs 'Deploying ' with a comment explainin Co-Authored-By: Claude Opus 4.6 * fix: add per-project partial-deploy inventory to deployAllAtOnce Align deploy-at-end failure reporting with install plugin PR #445: when the batch deploy fails mid-loop, log an explicit per-project inventory showing which projects were already published and which were not, instead of only reporting at the request level. Also moves the DEPLOYED state marking into the deploy loop so each project transitions TO_BE_DEPLOYED → DEPLOYED as soon as its contributing request completes, making the exactly-once guard and the partial-deploy inventory consistent. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../maven/plugins/deploy/DeployFileMojo.java | 45 ++++++++++-- .../maven/plugins/deploy/DeployMojo.java | 72 ++++++++++++++---- src/site/markdown/examples/deploy-ftp.md | 14 +++- .../markdown/examples/deploy-ssh-external.md | 4 + .../examples/deploying-in-legacy-layout.md.vm | 16 ++-- src/site/markdown/faq.md | 29 +++----- src/site/markdown/index.md.vm | 3 +- src/site/markdown/usage.md | 5 +- .../deploy/DeployFileMojoUnitTest.java | 73 +++++++++++++++++++ 9 files changed, 203 insertions(+), 58 deletions(-) diff --git a/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java b/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java index f9cdfda0..b62a026d 100644 --- a/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java +++ b/src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java @@ -32,6 +32,7 @@ import java.util.jar.JarEntry; import java.util.jar.JarFile; import java.util.regex.Pattern; +import java.util.stream.Collectors; import org.apache.maven.api.Artifact; import org.apache.maven.api.ProducedArtifact; @@ -209,6 +210,12 @@ public class DeployFileMojo extends AbstractDeployMojo { @Parameter(property = "maven.deploy.file.containedIn") private Path containedIn; + /** + * Whether {@link #pomFile} points at a temporary POM extracted from the artifact's jar (as + * opposed to an operator-supplied file): extracted POMs must be deleted after the deployment. + */ + private boolean pomFromJar; + void initProperties() throws MojoException { Path deployedPom; if (pomFile != null) { @@ -218,6 +225,7 @@ void initProperties() throws MojoException { deployedPom = readingPomFromJarFile(); if (deployedPom != null) { pomFile = deployedPom; + pomFromJar = true; } } @@ -230,10 +238,19 @@ private Path readingPomFromJarFile() { Pattern pomEntry = Pattern.compile("META-INF/maven/.*/pom\\.xml"); try { try (JarFile jarFile = new JarFile(file.toFile())) { - JarEntry entry = jarFile.stream() + List entries = jarFile.stream() .filter(e -> pomEntry.matcher(e.getName()).matches()) - .findFirst() - .orElse(null); + .collect(Collectors.toList()); + if (entries.size() > 1) { + // a shaded/multi-POM jar's author would otherwise choose which embedded POM + // fills in the missing coordinates (first match wins): require explicitness + getLog().warn("Found " + entries.size() + " POMs in " + file.getFileName() + " (" + + entries.stream().map(JarEntry::getName).collect(Collectors.joining(", ")) + + "); none will be used to derive coordinates. Specify pomFile or explicit" + + " groupId/artifactId/version/packaging."); + return null; + } + JarEntry entry = entries.isEmpty() ? null : entries.get(0); if (entry != null) { getLog().debug("Using " + entry.getName() + " as pomFile"); @@ -242,6 +259,10 @@ private Path readingPomFromJarFile() { if (base.indexOf('.') > 0) { base = base.substring(0, base.lastIndexOf('.')); } + while (base.length() < 3) { + // File.createTempFile rejects prefixes shorter than 3 characters + base = base + "_"; + } Path pomFile = File.createTempFile(base, ".pom").toPath(); Files.copy(pomInputStream, pomFile, StandardCopyOption.REPLACE_EXISTING); @@ -255,7 +276,9 @@ private Path readingPomFromJarFile() { } } } catch (IOException e) { - // ignore, artifact not packaged by Maven + // a corrupt (or hostile) jar must not silently degrade coordinate derivation + getLog().warn("Could not read a POM from " + file.getFileName() + ": " + e.getMessage() + + "; coordinates will not be derived from the artifact"); } return null; } @@ -334,7 +357,7 @@ public void execute() throws MojoException { ProducedArtifact artifact = session.createProducedArtifact( groupId, artifactId, version, classifier, isFilePom ? "pom" : getExtension(file), packaging); - if (file.equals(getLocalRepositoryFile(artifact))) { + if (isSameLocation(file, getLocalRepositoryFile(artifact))) { throw new MojoException("Cannot deploy artifact from the local repository: " + file); } @@ -469,7 +492,7 @@ public void execute() throws MojoException { } catch (ArtifactDeployerException e) { throw new MojoException(e.getMessage(), e); } finally { - if (pomFile == null && deployedPom != null) { + if ((pomFile == null || pomFromJar) && deployedPom != null) { try { Files.deleteIfExists(deployedPom); } catch (IOException e) { @@ -532,6 +555,16 @@ private Path getLocalRepositoryFile(Artifact artifact) { return session.getPathForLocalArtifact(artifact); } + /** + * Compares two paths as locations rather than spellings: a textual {@code Path.equals} lets a + * relative path, a symlink, or any non-canonical spelling of the same file slip past the + * local-repository self-deploy guard (an anti-footgun against local-repo metadata corruption, + * not a security boundary - but it should at least hold against trivial re-spellings). + */ + static boolean isSameLocation(Path a, Path b) { + return realOrNormalized(a).equals(realOrNormalized(b)); + } + /** * Process the supplied pomFile to get groupId, artifactId, version, and packaging * diff --git a/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java b/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java index 8c442ca6..3bd0627e 100644 --- a/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java +++ b/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java @@ -238,8 +238,11 @@ public void execute() { warnIfAffectedPackagingAndMaven(project.getPackaging().id()); if (!deployAtEnd) { - getLog().info("Deploying deploy for " + project.getGroupId() + ":" + project.getArtifactId() + ":" - + project.getVersion() + " at end"); + // this is the immediate-deploy branch: it must not claim the deploy happens "at + // end" - the deploy log is the operator's audit trail for what was deferred vs + // published immediately (the correct deferring message is in the else branch) + getLog().info("Deploying " + project.getGroupId() + ":" + project.getArtifactId() + ":" + + project.getVersion()); deploy(createDeployerRequest()); synchronized (DEPLOY_AT_END_LOCK) { putState(State.DEPLOYED); @@ -336,32 +339,73 @@ private void deployAllAtOnce() { if (!requests.isEmpty()) { // Requests are deployed sequentially and there is no rollback: if one fails, make the // partial-publication state explicit instead of only surfacing the failing module. - List deployedRepositoryIds = new ArrayList<>(); + List deployedProjects = new ArrayList<>(); for (ArtifactDeployerRequest request : requests) { try { deploy(request); } catch (RuntimeException e) { - if (!deployedRepositoryIds.isEmpty()) { - getLog().error("Deploy-at-end batch failed after " + deployedRepositoryIds.size() + " of " - + requests.size() + " deploy request(s) had already completed. Artifacts already" - + " published to repository id(s) " + String.join(", ", deployedRepositoryIds) - + " remain published: there is no rollback."); - } + logPartialDeployInventory(batchedProjects, deployedProjects, request); throw e; } - deployedRepositoryIds.add(request.getRepository().getId()); + // exactly-once: mark each project DEPLOYED as soon as its contributing request + // completes, so a concurrent or repeated trigger skips completed work — and the + // partial-deploy inventory can distinguish published from pending projects + for (Project reactorProject : batchedProjects) { + if (getState(reactorProject) == State.TO_BE_DEPLOYED) { + ArtifactDeployerRequest projRequest = (ArtifactDeployerRequest) + session.getPluginContext(reactorProject).get(ArtifactDeployerRequest.class.getName()); + if (request.getRepository().equals(projRequest.getRepository()) + && request.getRetryFailedDeploymentCount() + == projRequest.getRetryFailedDeploymentCount()) { + putState(reactorProject, State.DEPLOYED); + deployedProjects.add(reactorProject); + } + } + } } } else { getLog().info("No actual deploy requests"); } - // Mark every batched project DEPLOYED so a re-triggered batch (second bound deploy - // execution, or a direct deploy:deploy invocation walking the reactor) cannot publish - // the same artifacts a second time. Only reached when all requests deployed successfully. + // Any remaining TO_BE_DEPLOYED projects (should not happen here, but for completeness) + for (Project reactorProject : batchedProjects) { + if (getState(reactorProject) == State.TO_BE_DEPLOYED) { + putState(reactorProject, State.DEPLOYED); + } + } + } + + /** + * The contract documented on {@link #deployAtEnd} is all-or-nothing; when a deploy-at-end + * batch fails mid-loop that contract can no longer be met, so leave an explicit inventory + * of which projects already reached the remote repository and which were skipped, instead + * of failing silently into a mixed state. Mirrors the install plugin's partial-install + * inventory for consistent operator experience across both plugins. + */ + private void logPartialDeployInventory( + List batchedProjects, List deployedProjects, ArtifactDeployerRequest failedRequest) { + getLog().error("Deploy-at-end batch failed; the remote repository " + + failedRequest.getRepository().getId() + " (" + + redactUrlUserInfo(failedRequest.getRepository().getUrl()) + + ") is in a partially deployed state:"); for (Project reactorProject : batchedProjects) { - putState(reactorProject, State.DEPLOYED); + if (deployedProjects.contains(reactorProject)) { + getLog().error(" deployed: " + gav(reactorProject)); + } else { + getLog().error(" not deployed: " + gav(reactorProject)); + } + } + List skipped = getProjectsWithDeployExecution().stream() + .filter(p -> getState(p) == State.SKIPPED) + .collect(Collectors.toList()); + for (Project p : skipped) { + getLog().error(" skipped: " + gav(p)); } } + private static String gav(Project project) { + return project.getGroupId() + ":" + project.getArtifactId() + ":" + project.getVersion(); + } + private void deploy(ArtifactDeployerRequest request) { try { getLog().info("Deploying artifacts " + request.getArtifacts().toString() + " to repository " diff --git a/src/site/markdown/examples/deploy-ftp.md b/src/site/markdown/examples/deploy-ftp.md index c2d9fa45..a6e024c9 100644 --- a/src/site/markdown/examples/deploy-ftp.md +++ b/src/site/markdown/examples/deploy-ftp.md @@ -26,6 +26,14 @@ under the License. # Deployment of artifacts with FTP +**FTP is a cleartext protocol: credentials and artifacts cross the network unencrypted, and +anyone on the path can capture the deployment credential. Prefer deploying over HTTPS to a +repository manager.** The plugin refuses `ftp://` (and `http://`) deployment URLs to +non-loopback hosts by default; deploying over FTP requires an explicit +`-Dmaven.deploy.allowInsecureUrl=true` opt-out. Note also that the default Maven Resolver +transport does not support FTP at all: you must switch to the wagon transport +(`-Dmaven.resolver.transport=wagon`) in addition to declaring the extension below. + In order to deploy artifacts using FTP you must first specify the use of an FTP server in the **distributionManagement** element of your POM as well as specifying an `extension` in your `build` element which will pull in the FTP artifacts required to deploy with FTP: ```unknown @@ -52,7 +60,7 @@ In order to deploy artifacts using FTP you must first specify the use of an FTP ``` -Your `settings.xml` would contain a `server` element where the `id` of that element matches `id` of the FTP repository specified in the POM above: +Your `settings.xml` would contain a `server` element where the `id` of that element matches `id` of the FTP repository specified in the POM above. Store the password in [encrypted form](https://maven.apache.org/guides/mini/guide-encryption.html) rather than as clear text: ```unknown @@ -60,8 +68,8 @@ Your `settings.xml` would contain a `server` element where the `id` of that elem ftp-repository - user - pass + my-user + {encrypted-password} ... diff --git a/src/site/markdown/examples/deploy-ssh-external.md b/src/site/markdown/examples/deploy-ssh-external.md index 772baf02..d3a62c50 100644 --- a/src/site/markdown/examples/deploy-ssh-external.md +++ b/src/site/markdown/examples/deploy-ssh-external.md @@ -26,6 +26,10 @@ under the License. # Deployment of artifacts in an external SSH command +**Note:** the default Maven Resolver transport supports HTTP(S) and file URLs only. Deploying +over `scpexe://` requires switching to the wagon transport +(`-Dmaven.resolver.transport=wagon`) in addition to declaring the extension below. + In order to deploy artifacts using SSH you must first specify the use of an SSH server in the **distributionManagement** element of your POM as well as specifying an `extension` in your `build` element which will pull in the SSH artifacts required to deploy with SSH: ```unknown diff --git a/src/site/markdown/examples/deploying-in-legacy-layout.md.vm b/src/site/markdown/examples/deploying-in-legacy-layout.md.vm index bcb81769..4f539378 100644 --- a/src/site/markdown/examples/deploying-in-legacy-layout.md.vm +++ b/src/site/markdown/examples/deploying-in-legacy-layout.md.vm @@ -47,15 +47,11 @@ under the License. |---metadata ``` - In able to deploy an artifact in a legacy layout of repository, set the **repositoryLayout** parameter to `legacy` value. +**Legacy layout support was removed in version 3.0.0 of this plugin.** There is no +`repositoryLayout` parameter in the 3.x/4.x lines: Maven 3 and later only support the +default (Maven 2) repository layout, and a `-DrepositoryLayout=legacy` flag on the command +line is silently ignored by Maven (it does not select a legacy layout). - ```unknown - mvn ${project.groupId}:${project.artifactId}:${project.version}:deploy-file -Durl=file:///C:/m2-repo \ - -DrepositoryId=some.id \ - -Dfile=your-artifact-1.0.jar \ - -DpomFile=your-pom.xml \ - -DrepositoryLayout=legacy - ``` - - **Note**: By using the fully qualified path of a goal, you're ensured to be using the preferred version of the maven-deploy-plugin. When using `mvn deploy:deploy-file` its version depends on its specification in the pom or the version of Apache Maven. +To deploy into a Maven 1 (legacy) layout repository, use maven-deploy-plugin 2.x with Maven 2, +or convert the repository to the default layout with a repository manager. diff --git a/src/site/markdown/faq.md b/src/site/markdown/faq.md index 9a22a710..5cced06b 100644 --- a/src/site/markdown/faq.md +++ b/src/site/markdown/faq.md @@ -33,26 +33,15 @@ under the License. ### I get an Unsupported Protocol Error when deploying a 3rd party jar. What should I do? -If you are using the `deploy:deploy-file` goal and encounter this error: - -*"Error deploying artifact: Unsupported Protocol: 'ftp': Cannot find -wagon which supports the requested protocol: ftp"* - -Then you need to place the appropriate wagon provider in your `%M2_HOME%/lib`. In -this case the provider needed is ftp, so we have to place the wagon-ftp jar in the -lib directory of your Maven 2 installation. - -As an alternative to placing the wagon provider into the Maven distribution, you can -also create a dummy POM that declares the required wagon as an `` inside -the current directory. - -If the error description is something like this: - -*"Error deploying artifact: Unsupported Protocol: 'ftp': Cannot find -wagon which supports the requested protocol: ftp -org/apache/commons/net/ftp/FTP"* - -Then you need to place the commons-net jar in `%M2_HOME%/lib`. +Deployment uses the [Maven Resolver transport](https://maven.apache.org/guides/mini/guide-resolver-transport.html), +which supports `https://` (and `http://`) plus `file://` URLs out of the box. Other protocols +such as FTP, SCP or SFTP are not available by default: they require switching to the wagon +transport (`-Dmaven.resolver.transport=wagon`) and declaring the corresponding wagon provider +as a build `` in your POM. + +Where possible, prefer deploying over HTTPS to a repository manager instead: it needs no extra +extensions and does not send credentials in the clear (see the +[HTTP(S) deployment example](./examples/deploy-http.html)). diff --git a/src/site/markdown/index.md.vm b/src/site/markdown/index.md.vm index 4b62e54e..9710e0db 100644 --- a/src/site/markdown/index.md.vm +++ b/src/site/markdown/index.md.vm @@ -32,9 +32,8 @@ As a repository contains more than JAR files (POMs, the metadata, MD5 and SHA1 h To work, the deployment will require: -- information about the repository: its location, the transport method used to access it (FTP, SCP, SFTP\.\.\.) and the optional user specific required account information +- information about the repository: its location and the optional user specific required account information; prefer `https://` URLs - the default Maven Resolver transport supports HTTP(S) and file URLs, while other protocols (FTP, SCP, SFTP\.\.\.) need the wagon transport plus a matching build extension - information about the artifact(s): the group, artifact, version, packaging, classifier\.\.\. -- a deployer: a method to actually perform the deployment. This can be implemented as a wagon transport (making it cross-platform), or use a system specific method. The information will be taken from the implied (or specified) pom and from the command line. The settings.xml file may also be parsed to retrieve user credentials. diff --git a/src/site/markdown/usage.md b/src/site/markdown/usage.md index 00bf6c93..6a7915aa 100644 --- a/src/site/markdown/usage.md +++ b/src/site/markdown/usage.md @@ -75,7 +75,7 @@ mvn deploy ## The `deploy:deploy-file` Mojo -The `deploy:deploy-file` mojo is used primarily for deploying artifacts, which were not built by Maven. The project's development team may or may not provide a POM for the artifact, and in some cases you may want to deploy the artifact to an internal remote repository. The deploy-file mojo provides functionality covering all of these use cases, and offers a wide range of configurability for generating a POM on-the-fly. Additionally, you can specify what layout your repository uses. The full usage statement of the deploy-file mojo can be described as: +The `deploy:deploy-file` mojo is used primarily for deploying artifacts, which were not built by Maven. The project's development team may or may not provide a POM for the artifact, and in some cases you may want to deploy the artifact to an internal remote repository. The deploy-file mojo provides functionality covering all of these use cases, and offers a wide range of configurability for generating a POM on-the-fly. The full usage statement of the deploy-file mojo can be described as: ```unknown mvn deploy:deploy-file -Durl=file://C:\m2-repo \ @@ -88,8 +88,7 @@ mvn deploy:deploy-file -Durl=file://C:\m2-repo \ [-Dpackaging=jar] \ [-Dclassifier=test] \ [-DgeneratePom=true] \ - [-DgeneratePom.description="My Project Description"] \ - [-DrepositoryLayout=legacy] + [-DgeneratePom.description="My Project Description"] ``` If the following required information is not specified in some way, the goal will fail: diff --git a/src/test/java/org/apache/maven/plugins/deploy/DeployFileMojoUnitTest.java b/src/test/java/org/apache/maven/plugins/deploy/DeployFileMojoUnitTest.java index 3c95dd14..50e0e1dd 100644 --- a/src/test/java/org/apache/maven/plugins/deploy/DeployFileMojoUnitTest.java +++ b/src/test/java/org/apache/maven/plugins/deploy/DeployFileMojoUnitTest.java @@ -25,12 +25,15 @@ import org.apache.maven.api.model.Model; import org.apache.maven.api.model.Parent; +import org.apache.maven.api.plugin.Log; import org.apache.maven.api.plugin.MojoException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.Mockito; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -181,6 +184,76 @@ void containmentDirectoryIsEnforced() throws IOException { assertFalse(DeployFileMojo.isContainedIn(Paths.get("/etc/passwd"), root)); } + @Test + void multiPomJarDoesNotDeriveCoordinates() throws Exception { + mojo.logger = Mockito.mock(Log.class); + Path jar = createJar("multi.jar", "META-INF/maven/g1/a1/pom.xml", "META-INF/maven/g2/a2/pom.xml"); + mojo.file = jar; + setMojoModel(mojo, "group", "artifact", "version", "packaging", null); + + mojo.initProperties(); + + // neither embedded POM may be chosen to fill in coordinates + assertNull(mojo.getGroupId()); + assertNull(mojo.getArtifactId()); + assertNull(mojo.getVersion()); + } + + @Test + void shortJarFileNameDoesNotCrashTempPomCreation() throws Exception { + mojo.logger = Mockito.mock(Log.class); + // basename "a" is shorter than File.createTempFile's 3-character prefix minimum + Path jar = createJar("a.jar", "META-INF/maven/g/a/pom.xml"); + mojo.file = jar; + setMojoModel(mojo, "group", "artifact", "version", "packaging", null); + + mojo.initProperties(); + + assertEquals("group", mojo.getGroupId()); + assertEquals("artifact", mojo.getArtifactId()); + } + + @Test + void corruptJarWarnsAndDerivesNothing() throws Exception { + mojo.logger = Mockito.mock(Log.class); + Path dir = java.nio.file.Files.createTempDirectory("deploy-file-test"); + Path jar = dir.resolve("corrupt.jar"); + java.nio.file.Files.write(jar, new byte[] {0x00, 0x01, 0x02, 0x03}); + mojo.file = jar; + setMojoModel(mojo, "group", "artifact", "version", "packaging", null); + + mojo.initProperties(); + + assertNull(mojo.getGroupId()); + Mockito.verify(mojo.logger).warn(Mockito.contains("Could not read a POM from")); + } + + @Test + void selfDeployGuardComparesLocationsNotSpellings() throws Exception { + Path dir = java.nio.file.Files.createTempDirectory("deploy-file-test"); + Path real = java.nio.file.Files.createFile(dir.resolve("artifact.jar")); + + assertTrue(DeployFileMojo.isSameLocation(real, real)); + assertTrue(DeployFileMojo.isSameLocation(real, dir.resolve("sub/../artifact.jar"))); + Path link = java.nio.file.Files.createSymbolicLink(dir.resolve("link.jar"), real); + assertTrue(DeployFileMojo.isSameLocation(link, real)); + assertFalse(DeployFileMojo.isSameLocation(real, dir.resolve("other.jar"))); + } + + private static Path createJar(String name, String... entries) throws java.io.IOException { + Path dir = java.nio.file.Files.createTempDirectory("deploy-file-test"); + Path jar = dir.resolve(name); + try (java.util.jar.JarOutputStream jos = + new java.util.jar.JarOutputStream(java.nio.file.Files.newOutputStream(jar))) { + for (String entry : entries) { + jos.putNextEntry(new java.util.jar.JarEntry(entry)); + jos.write("".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + jos.closeEntry(); + } + } + return jar; + } + private void setMojoModel( MockDeployFileMojo mojo, String group, String artifact, String version, String packaging, Parent parent) { mojo.model = Model.newBuilder()