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")
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")
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..6e155dbb 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.
*/
@@ -420,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 3a74b8a9..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;
@@ -183,13 +184,38 @@ 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")
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;
+
+ /**
+ * 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) {
@@ -199,6 +225,7 @@ void initProperties() throws MojoException {
deployedPom = readingPomFromJarFile();
if (deployedPom != null) {
pomFile = deployedPom;
+ pomFromJar = true;
}
}
@@ -211,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");
@@ -223,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);
@@ -236,16 +276,17 @@ 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;
}
@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;
}
@@ -256,6 +297,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, "/"));
@@ -279,6 +327,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.");
}
@@ -300,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);
}
@@ -380,6 +437,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();
@@ -426,13 +484,15 @@ 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) {
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) {
@@ -445,6 +505,48 @@ 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) {
+ // 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;
+ }
+ }
+
/**
* Gets the path of the specified artifact within the local repository. Note that the returned path need not exist
* (yet).
@@ -453,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 3e7e9770..3bd0627e 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;
@@ -147,7 +151,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 +225,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);
@@ -232,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);
@@ -330,36 +339,78 @@ 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) {
- putState(reactorProject, State.DEPLOYED);
+ 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) {
+ 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 "
- + request.getRepository());
+ + request.getRepository().getId() + " ("
+ + redactUrlUserInfo(request.getRepository().getUrl()) + ")");
getArtifactDeployer().deploy(request);
} catch (MojoException e) {
throw e;
@@ -445,7 +496,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);
@@ -455,8 +506,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(
@@ -477,6 +539,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);
}
}
@@ -485,19 +548,31 @@ 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());
+ warnIfPolicyMismatch(dm.getSnapshotRepository(), isSnapshot);
repo = session.createRemoteRepository(dm.getSnapshotRepository());
} 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());
+ warnIfPolicyMismatch(dm.getRepository(), isSnapshot);
repo = session.createRemoteRepository(dm.getRepository());
}
}
@@ -513,6 +588,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
@@ -575,6 +664,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/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 21f41759..50e0e1dd 100644
--- a/src/test/java/org/apache/maven/plugins/deploy/DeployFileMojoUnitTest.java
+++ b/src/test/java/org/apache/maven/plugins/deploy/DeployFileMojoUnitTest.java
@@ -18,17 +18,22 @@
*/
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;
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;
/**
@@ -155,6 +160,100 @@ 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"));
+ }
+
+ @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));
+ }
+
+ @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()
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..58175e66 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,104 @@ 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));
+ }
+
+ @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());
+ }
+
+ @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());
+ }
+
+ @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());