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 f9cdfda..b62a026 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 8c442ca..3bd0627 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 c2d9fa4..a6e024c 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 772baf0..d3a62c5 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 bcb8176..4f53937 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 9a22a71..5cced06 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 4b62e54..9710e0d 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 00bf6c9..6a7915a 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 3c95dd1..50e0e1d 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()