You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by sl...@apache.org on 2026/09/04 11:33:16 UTC

(maven-wagon) branch master updated: Give MKCOL the status vocabulary RFC 4918 defines

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

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


The following commit(s) were added to refs/heads/master by this push:
     new c0a7f255 Give MKCOL the status vocabulary RFC 4918 defines
c0a7f255 is described below

commit c0a7f2553c7fdcafbcacf03bf45db1db7a234560
Author: Sylwester Lachiewicz <sl...@apache.org>
AuthorDate: Fri Sep 4 11:49:18 2026 +0200

    Give MKCOL the status vocabulary RFC 4918 defines
    
    Only 409 means an ancestor is missing, so only 409 should send the walk
    further back; treating every other answer that way turned a 403 or an
    authentication failure into a confusing error about the top of the path.
    On the way down, 405 means the collection is already there, which is as
    good as having created it -- rejecting it failed uploads whenever a
    collection appeared underneath us. Do not narrow either set back to
    201-only; the walk is not atomic and the server is entitled to both.
---
 .../maven/wagon/providers/webdav/WebDavWagon.java  |  39 ++++++-
 .../providers/webdav/WebDavWagonMkColTest.java     | 126 +++++++++++++++++++++
 2 files changed, 159 insertions(+), 6 deletions(-)

diff --git a/wagon-providers/wagon-webdav-jackrabbit/src/main/java/org/apache/maven/wagon/providers/webdav/WebDavWagon.java b/wagon-providers/wagon-webdav-jackrabbit/src/main/java/org/apache/maven/wagon/providers/webdav/WebDavWagon.java
index 49ef3625..8d2cc95f 100644
--- a/wagon-providers/wagon-webdav-jackrabbit/src/main/java/org/apache/maven/wagon/providers/webdav/WebDavWagon.java
+++ b/wagon-providers/wagon-webdav-jackrabbit/src/main/java/org/apache/maven/wagon/providers/webdav/WebDavWagon.java
@@ -108,30 +108,57 @@ protected void mkdirs(String dir) throws IOException {
         // create relative path that will always have a leading and trailing slash
         String relpath = FileUtils.normalize(getPath(basedir, dir) + "/");
 
+        mkColPath(baseUrl, relpath);
+    }
+
+    /**
+     * Creates every missing collection along {@code relpath}, one at a time.
+     * <p>
+     * RFC 4918 section 9.3.1 gives MKCOL a small vocabulary, and only two of the answers mean "keep going".
+     * {@code 201 Created} is a collection we made, {@code 405 Method Not Allowed} one that was already there, and
+     * {@code 409 Conflict} says an ancestor is missing -- which is the only reason to step further back. Anything
+     * else, a {@code 403} or an authentication failure for instance, is reported where it happens rather than
+     * being mistaken for a missing parent.
+     *
+     * @param baseUrl scheme, host and port of the repository, without a trailing slash
+     * @param relpath repository-relative path with a leading and trailing slash
+     */
+    void mkColPath(String baseUrl, String relpath) throws IOException {
         PathNavigator navigator = new PathNavigator(relpath);
 
-        // traverse backwards until we hit a directory that already exists (OK/NOT_ALLOWED), or that we were able to
-        // create (CREATED), or until we get to the top of the path
-        int status = -1;
+        // step backwards while the server reports a missing ancestor, until one exists or we create one
+        int status;
         do {
             String url = baseUrl + "/" + navigator.getPath();
             status = doMkCol(url);
             if (status == HttpStatus.SC_CREATED || status == HttpStatus.SC_METHOD_NOT_ALLOWED) {
                 break;
             }
+            if (status != HttpStatus.SC_CONFLICT) {
+                throw new IOException(unableToCreate(url, status));
+            }
         } while (navigator.backward());
 
+        if (status != HttpStatus.SC_CREATED && status != HttpStatus.SC_METHOD_NOT_ALLOWED) {
+            throw new IOException(unableToCreate(baseUrl + "/" + navigator.getPath(), status));
+        }
+
         // traverse forward creating missing directories
         while (navigator.forward()) {
             String url = baseUrl + "/" + navigator.getPath();
             status = doMkCol(url);
-            if (status != HttpStatus.SC_CREATED) {
-                throw new IOException("Unable to create collection: " + url + "; status code = " + status);
+            // a collection that already exists is as good as one we just created
+            if (status != HttpStatus.SC_CREATED && status != HttpStatus.SC_METHOD_NOT_ALLOWED) {
+                throw new IOException(unableToCreate(url, status));
             }
         }
     }
 
-    private int doMkCol(String url) throws IOException {
+    private static String unableToCreate(String url, int status) {
+        return "Unable to create collection: " + url + "; status code = " + status;
+    }
+
+    int doMkCol(String url) throws IOException {
         DavMethods.HttpMkcol method = new DavMethods.HttpMkcol(url);
         try (CloseableHttpResponse closeableHttpResponse = execute(method)) {
             return closeableHttpResponse.getStatusLine().getStatusCode();
diff --git a/wagon-providers/wagon-webdav-jackrabbit/src/test/java/org/apache/maven/wagon/providers/webdav/WebDavWagonMkColTest.java b/wagon-providers/wagon-webdav-jackrabbit/src/test/java/org/apache/maven/wagon/providers/webdav/WebDavWagonMkColTest.java
new file mode 100644
index 00000000..a906383d
--- /dev/null
+++ b/wagon-providers/wagon-webdav-jackrabbit/src/test/java/org/apache/maven/wagon/providers/webdav/WebDavWagonMkColTest.java
@@ -0,0 +1,126 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.wagon.providers.webdav;
+
+import java.io.IOException;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Deque;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Drives the MKCOL status handling of {@link WebDavWagon#mkColPath} with scripted responses, which the
+ * server-backed tests in {@link WebDavWagonTest} cannot do: a real server never answers 403 on demand, and never
+ * loses a race that turns a forward MKCOL into a 405.
+ */
+class WebDavWagonMkColTest {
+
+    private static final String BASE = "http://host";
+
+    @Test
+    void createsEveryMissingCollectionFromTheTopDown() throws IOException {
+        // nothing exists, so the walk back reports a missing ancestor until the root, then creates on the way down
+        ScriptedWagon wagon = new ScriptedWagon(409, 409, 409, 201, 201, 201, 201);
+
+        wagon.mkColPath(BASE, "/a/b/c/");
+
+        assertEquals(
+                Arrays.asList(
+                        BASE + "/a/b/c/",
+                        BASE + "/a/b/",
+                        BASE + "/a/",
+                        BASE + "/",
+                        BASE + "/a/",
+                        BASE + "/a/b/",
+                        BASE + "/a/b/c/"),
+                wagon.requested);
+    }
+
+    @Test
+    void stopsWalkingBackAtACollectionThatAlreadyExists() throws IOException {
+        // 405 means "already a collection here", so the walk back stops and only the rest is created
+        ScriptedWagon wagon = new ScriptedWagon(409, 409, 405, 201, 201);
+
+        wagon.mkColPath(BASE, "/a/b/c/");
+
+        assertEquals(5, wagon.requested.size());
+        assertEquals(BASE + "/a/", wagon.requested.get(2));
+    }
+
+    @Test
+    void toleratesACollectionAppearingUnderneathUsOnTheWayDown() throws IOException {
+        // someone else created a/b between our walk back and our walk down; 405 there is not a failure
+        ScriptedWagon wagon = new ScriptedWagon(409, 409, 405, 405, 201);
+
+        wagon.mkColPath(BASE, "/a/b/c/");
+
+        assertEquals(5, wagon.requested.size());
+    }
+
+    @Test
+    void reportsARefusalWhereItHappensRatherThanWalkingPastIt() {
+        // 403 is not "the parent is missing", so it must stop at once instead of walking to the root
+        ScriptedWagon wagon = new ScriptedWagon(403);
+
+        IOException e = assertThrows(IOException.class, () -> wagon.mkColPath(BASE, "/a/b/c/"));
+
+        assertEquals(1, wagon.requested.size(), "walked past a refusal instead of reporting it");
+        assertTrue(e.getMessage().contains(BASE + "/a/b/c/"), e.getMessage());
+        assertTrue(e.getMessage().contains("403"), e.getMessage());
+    }
+
+    @Test
+    void failsWhenTheWholePathIsExhaustedAndStillConflicting() {
+        // the server claims a missing ancestor all the way to the root; there is nowhere left to go
+        ScriptedWagon wagon = new ScriptedWagon(409, 409, 409, 409);
+
+        IOException e = assertThrows(IOException.class, () -> wagon.mkColPath(BASE, "/a/b/c/"));
+
+        assertEquals(4, wagon.requested.size());
+        assertTrue(e.getMessage().contains("409"), e.getMessage());
+    }
+
+    private static final class ScriptedWagon extends WebDavWagon {
+
+        private final Deque<Integer> statuses = new ArrayDeque<>();
+        private final List<String> requested = new ArrayList<>();
+
+        ScriptedWagon(int... statuses) {
+            for (int status : statuses) {
+                this.statuses.add(status);
+            }
+        }
+
+        @Override
+        int doMkCol(String url) {
+            requested.add(url);
+            if (statuses.isEmpty()) {
+                throw new IllegalStateException("unexpected MKCOL of " + url + "; the script ran out");
+            }
+            return statuses.removeFirst();
+        }
+    }
+}