You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by sj...@apache.org on 2026/09/08 18:51:21 UTC

(maven-deploy-plugin) branch master updated: Security audit: LOW robustness fixes — skip, fallback, policy, redaction, containment, parse (#699)

This is an automated email from the ASF dual-hosted git repository.

slawekjaranowski pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/maven-deploy-plugin.git


The following commit(s) were added to refs/heads/master by this push:
     new c6baa47  Security audit: LOW robustness fixes — skip, fallback, policy, redaction, containment, parse (#699)
c6baa47 is described below

commit c6baa4718474c211bc036df0f7e9a7ca462c9b35
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Tue Sep 8 20:51:15 2026 +0200

    Security audit: LOW robustness fixes — skip, fallback, policy, redaction, containment, parse (#699)
    
    * fix: f001 (MEDIUM) — Keeps v1's structure: AbstractDeployMojo.validateCredentialBinding guard
    
    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): validateCre [...]
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
    
    * fix: f002 (MEDIUM) — Single choke point: AbstractDeployMojo.validateTransportSecurity(id,url)
    
    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 -Dmave [...]
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
    
    * fix: f003 (MEDIUM) — Both legs of the root cause closed with shared validators hoisted to Abs
    
    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 [...]
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
    
    * fix: f004 (MEDIUM) — Two-sided state fix, per the triage recommendation: (1) deployAllAtOnce
    
    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 e [...]
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
    
    * fix: f005 (MEDIUM) — A single class-level monitor (DEPLOY_AT_END_LOCK, a constant lock object
    
    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 [...]
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
    
    * fix: f006 (MEDIUM) — Verified-true doc fix plus visibility escalation, per the finding brief.
    
    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  [...]
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
    
    * 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 <no...@anthropic.com>
    
    * 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 <no...@anthropic.com>
    
    * fix: f007 (LOW) — Shared fail-closed parser AbstractDeployMojo.parseSkipMode(value, parame
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
    
    * fix: f008 (LOW) — The dm selection now distinguishes 'no snapshotRepository declared' (doc
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
    
    * fix: f009 (LOW) — Warn-based client-side guard, honest about the static limit the audit re
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
    
    * fix: f010 (LOW) — New AbstractDeployMojo.redactUrlUserInfo() masks scheme://userinfo@ as s
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
    
    * fix: f011 (LOW) — Opt-in containment knob, per the triage recommendation (pipeline variabl
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
    
    * fix: f012 (LOW) — Two targeted rejections rather than a pattern-order swap: reordering the
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
    
    * 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 <no...@anthropic.com>
    
    * 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 <no...@anthropic.com>
    
    * fix: resolve symlinks in isContainedIn for non-existent paths
    
    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 <no...@anthropic.com>
    
    * Security audit: LOW/INFO docs and minor fixes — docs refresh, jar robustness, guard, log (#700)
    
    * fix: f013 (LOW) — Verified-true doc fixes (each claim checked against src/main before writ
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
    
    * fix: f014 (LOW) — All four fixed in readingPomFromJarFile/initProperties/finally: (1) the
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
    
    * fix: f015 (LOW) — The guard now compares locations: isSameLocation() resolves both sides t
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
    
    * fix: f016 (INFO) — The immediate branch now logs 'Deploying <gav>' with a comment explainin
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
    
    * 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 <no...@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 <no...@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 <no...@anthropic.com>
    Co-authored-by: Slawomir Jaranowski <s....@gmail.com>
    Co-authored-by: Copilot <22...@users.noreply.github.com>
---
 src/it/deployfile-release-skip/verify.groovy       |   4 +-
 src/it/deployfile-snapshot-skip/verify.groovy      |   4 +-
 .../maven/plugins/deploy/AbstractDeployMojo.java   |  47 +++++++
 .../maven/plugins/deploy/DeployFileMojo.java       | 134 ++++++++++++++++--
 .../apache/maven/plugins/deploy/DeployMojo.java    | 152 +++++++++++++++++----
 src/site/markdown/examples/deploy-ftp.md           |  14 +-
 src/site/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 +-
 .../plugins/deploy/DeployFileMojoUnitTest.java     |  99 ++++++++++++++
 .../maven/plugins/deploy/DeployMojoTest.java       |  98 +++++++++++++
 13 files changed, 535 insertions(+), 74 deletions(-)

diff --git a/src/it/deployfile-release-skip/verify.groovy b/src/it/deployfile-release-skip/verify.groovy
index c476e40..69e17c0 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 ab46c59..44acc9e 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 e9039cb..6e155db 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 @@ public abstract class AbstractDeployMojo implements Mojo {
         }
     }
 
+    /**
+     * Recognized values of the {@code skip} tri-state shared by both mojos.
+     */
+    enum SkipMode {
+        NONE,
+        ALL,
+        RELEASES,
+        SNAPSHOTS
+    }
+
+    /**
+     * Parses the {@code skip} tri-state <em>fail-closed</em>: {@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 @@ public abstract class AbstractDeployMojo implements Mojo {
         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 3a74b8a..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.List;
 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 {
      *     <li><code>true</code>: will skip as usual</li>
      *     <li><code>releases</code>: will skip if current version of the project is a release</li>
      *     <li><code>snapshots</code>: will skip if current version of the project is a snapshot</li>
-     *     <li>any other values will be considered as <code>false</code></li>
+     *     <li>values are matched case-insensitively; any other value fails the build (fail-closed:
+     *     a typo in a publish-suppression control must not silently publish)</li>
      * </ul>
+     * The <code>releases</code>/<code>snapshots</code> variants are evaluated after the artifact
+     * coordinates are known, so a version supplied only via <code>pomFile</code> (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.
+     * <p>
+     * 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 @@ public class DeployFileMojo extends AbstractDeployMojo {
             deployedPom = readingPomFromJarFile();
             if (deployedPom != null) {
                 pomFile = deployedPom;
+                pomFromJar = true;
             }
         }
 
@@ -211,10 +238,19 @@ public class DeployFileMojo extends AbstractDeployMojo {
         Pattern pomEntry = Pattern.compile("META-INF/maven/.*/pom\\.xml");
         try {
             try (JarFile jarFile = new JarFile(file.toFile())) {
-                JarEntry entry = jarFile.stream()
+                List<JarEntry> 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 @@ public class DeployFileMojo extends AbstractDeployMojo {
                         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 @@ public class DeployFileMojo extends AbstractDeployMojo {
                 }
             }
         } 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 class DeployFileMojo extends AbstractDeployMojo {
             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 class DeployFileMojo extends AbstractDeployMojo {
                     + "'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 class DeployFileMojo extends AbstractDeployMojo {
         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 class DeployFileMojo extends AbstractDeployMojo {
                     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 class DeployFileMojo extends AbstractDeployMojo {
                     .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 class DeployFileMojo extends AbstractDeployMojo {
         }
     }
 
+    /**
+     * 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 @@ public class DeployFileMojo extends AbstractDeployMojo {
         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 3e7e977..3bd0627 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 <code>settings.xml</code> server entry and the URL differs from every URL this build
      * associates with that id, the deployment is refused unless
      * <code>-Dmaven.deploy.allowCredentialReuse=true</code> is given on the command line.
+     * <p>
+     * <b>Policy note:</b> unlike a repository declared in <code>distributionManagement</code>, an alternative
+     * repository is built from just <code>id::url</code> 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 {
      *     <li><code>true</code>: will skip as usual</li>
      *     <li><code>releases</code>: will skip if current version of the project is a release</li>
      *     <li><code>snapshots</code>: will skip if current version of the project is a snapshot</li>
-     *     <li>any other values will be considered as <code>false</code></li>
+     *     <li>values are matched case-insensitively; any other value fails the build (fail-closed:
+     *     a typo in a publish-suppression control must not silently publish)</li>
      * </ul>
      * @since 2.4
      */
@@ -220,9 +225,10 @@ public class DeployMojo extends AbstractDeployMojo {
                 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 class DeployMojo extends AbstractDeployMojo {
             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 @@ public class DeployMojo extends AbstractDeployMojo {
         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<String> deployedRepositoryIds = new ArrayList<>();
+            List<Project> 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<Project> batchedProjects, List<Project> 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<Project> 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 @@ public class DeployMojo extends AbstractDeployMojo {
         }
 
         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 @@ public class DeployMojo extends AbstractDeployMojo {
                 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 @@ public class DeployMojo extends AbstractDeployMojo {
                     String id = matcher.group(1).trim();
                     String url = matcher.group(2).trim();
 
+                    requireNonEmptyIdAndUrl(altDeploymentRepo, id, url);
                     repo = createAltDeploymentRepository(id, url);
                 }
             }
@@ -485,19 +548,31 @@ public class DeployMojo extends AbstractDeployMojo {
         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 <snapshotRepository> whose id or url is"
+                                + " empty; falling back to the release <repository> '"
+                                + 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 @@ public class DeployMojo extends AbstractDeployMojo {
         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 @@ public class DeployMojo extends AbstractDeployMojo {
         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") + "><enabled>false</enabled>, 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 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
 </project>
 ```
 
-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
 <settings>
@@ -60,8 +68,8 @@ Your `settings.xml` would contain a `server` element where the `id` of that elem
   <servers>
     <server>
       <id>ftp-repository</id>
-      <username>user</username>
-      <password>pass</password>
+      <username>my-user</username>
+      <password>{encrypted-password}</password>
     </server>
   </servers>
   ...
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&apos;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:
-
-*&quot;Error deploying artifact: Unsupported Protocol: &apos;ftp&apos;: Cannot find
-wagon which supports the requested protocol: ftp&quot;*
-
-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 `<extension>` inside
-the current directory.
-
-If the error description is something like this:
-
-*&quot;Error deploying artifact: Unsupported Protocol: &apos;ftp&apos;: Cannot find
-wagon which supports the requested protocol: ftp
-org/apache/commons/net/ftp/FTP&quot;*
-
-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 `<extension>` 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)).
 
 <a id="skip"></a>
 
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&apos;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 `deploy:deploy-file` mojo is used primarily for deploying artifacts, which were not built by Maven. The project&apos;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 21f4175..50e0e1d 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 @@ class DeployFileMojoUnitTest {
         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("<project/>".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 8f3aeae..58175e6 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 @@ class DeployMojoTest {
         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<ArtifactDeployerRequest> requestCaptor = ArgumentCaptor.forClass(ArtifactDeployerRequest.class);
         doNothing().when(artifactDeployer).deploy(requestCaptor.capture());