From f217c8a2aa8e3b2b41ffb54eaa80f8874d4062ca Mon Sep 17 00:00:00 2001 From: Nick Craig-Wood Date: Mon, 5 Oct 2026 16:16:18 +0100 Subject: [PATCH] webdav: fix "XML syntax error" on paths which don't exist with ownCloud 10.16 ownCloud 10.16 answers a PROPFIND for a path which doesn't exist with a 207 Multi-Status response, rather than a 404, with a body which starts a multistatus document then appends a Sabre NotFound error to it. This isn't valid XML so rclone failed with read metadata failed: XML syntax error on line 2: expected attribute name in element whenever it was pointed at a directory which didn't exist yet. Detect the NotFound error in the response and treat it as a 404. --- backend/webdav/webdav.go | 39 ++++++++++++++++++++-- backend/webdav/webdav_internal_test.go | 46 ++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/backend/webdav/webdav.go b/backend/webdav/webdav.go index 4f1d7692b..3310887e2 100644 --- a/backend/webdav/webdav.go +++ b/backend/webdav/webdav.go @@ -345,6 +345,41 @@ func itemIsDir(item *api.Response) bool { return false } +// sabreNotFound is the exception SabreDAV based servers report for a +// path which doesn't exist +var sabreNotFound = []byte(`Sabre\DAV\Exception\NotFound`) + +// callPropfind does the PROPFIND in opts and decodes the response into result +// +// ownCloud 10.16 starts a 207 Multi-Status response before it finds +// that the path doesn't exist, then appends a Sabre NotFound error to +// it which makes it invalid XML. This is returned as the 404 *api.Error +// which servers normally give for a path which doesn't exist. +func (f *Fs) callPropfind(ctx context.Context, opts *rest.Opts, result *api.Multistatus) (resp *http.Response, err error) { + resp, err = f.srv.Call(ctx, opts) + if err != nil { + return resp, err + } + // Keep the start of the body to look for the error in + start := make([]byte, 4096) + n, _ := io.ReadFull(resp.Body, start) + start = start[:n] + body := resp.Body + resp.Body = struct { + io.Reader + io.Closer + }{io.MultiReader(bytes.NewReader(start), body), body} + err = rest.DecodeXML(resp, result) + if err != nil && bytes.Contains(start, sabreNotFound) { + return resp, &api.Error{ + Exception: string(sabreNotFound), + Status: http.StatusText(http.StatusNotFound), + StatusCode: http.StatusNotFound, + } + } + return resp, err +} + // readMetaDataForPath reads the metadata from the path func (f *Fs) readMetaDataForPath(ctx context.Context, path string) (info *api.Prop, err error) { // FIXME how do we read back additional properties? @@ -366,7 +401,7 @@ func (f *Fs) readMetaDataForPath(ctx context.Context, path string) (info *api.Pr var resp *http.Response err = f.pacer.Call(func() (bool, error) { var attempt api.Multistatus - resp, err = f.srv.CallXML(ctx, &opts, nil, &attempt) + resp, err = f.callPropfind(ctx, &opts, &attempt) if err == nil { result = attempt } @@ -820,7 +855,7 @@ func (f *Fs) listAll(ctx context.Context, dir string, directoriesOnly bool, file var resp *http.Response err = f.pacer.Call(func() (bool, error) { var attempt api.Multistatus - resp, err = f.srv.CallXML(ctx, &opts, nil, &attempt) + resp, err = f.callPropfind(ctx, &opts, &attempt) if err == nil { result = attempt } diff --git a/backend/webdav/webdav_internal_test.go b/backend/webdav/webdav_internal_test.go index 2902ecb92..f5de11d8b 100644 --- a/backend/webdav/webdav_internal_test.go +++ b/backend/webdav/webdav_internal_test.go @@ -354,3 +354,49 @@ func TestListAllRetryDoesNotConcatenate(t *testing.T) { } assert.ElementsMatch(t, want, remotes) } + +// TestNotFoundInMultistatus checks that a missing path is detected when the +// server reports it by appending a Sabre NotFound error to a 207 response it +// has already started, as ownCloud 10.16 does, rather than by returning 404. +func TestNotFoundInMultistatus(t *testing.T) { + head := `` + "\n" + `/HTTP/1.1 200 OK` + notFound := head + ` + + Sabre\DAV\Exception\NotFound + File with name missing could not be located + +` + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "PROPFIND", r.Method) + w.Header().Set("Content-Type", "application/xml; charset=utf-8") + w.WriteHeader(207) + body := notFound + if r.URL.Path == "/" { + body = root + } + _, err := fmt.Fprint(w, body) + require.NoError(t, err) + })) + defer ts.Close() + + configfile.Install() + m := configmap.Simple{ + "type": "webdav", + "url": ts.URL, + } + ctx := context.Background() + + // A root which doesn't exist yet + _, err := webdav.NewFs(ctx, remoteName, "missing", m) + require.NoError(t, err) + + f, err := webdav.NewFs(ctx, remoteName, "", m) + require.NoError(t, err) + + _, err = f.NewObject(ctx, "missing") + assert.Equal(t, fs.ErrorObjectNotFound, err) + + _, err = f.List(ctx, "missing") + assert.Equal(t, fs.ErrorDirNotFound, err) +}