Files
尔立andClaude Code 93a1e3e7fc fix(davsync): detect a WebDAV write the server kept short (#531)
Relays and tunnels cut long uploads short. Over a Cloudflare Tunnel
with a 3MB write this came up 2 of 3 times, and each time the client
saw it happen: the relay answered 502 or 423, which the sync reports
as itself. The quiet shape is a server that answers 2xx with half the
file on disk: every later sync then reads the half and fails as "not
a magpie backup", with no way to tell what actually went wrong.

A write is now looked at after it lands: a HEAD read of the backup is
compared with the size sent, a short write is retried over the version
the short one made (its ETag, from the PUT's answer or the HEAD's), and
after three tries reported as itself. A HEAD that can't be done — an
error, a 429 or 503, a non-200 — leaves the write unchecked: it landed
either way. Servers without HEAD check as before.

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-10-02 16:42:37 +08:00

271 lines
9.0 KiB
Go

package davsync
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
)
// A 403 is told apart: a folder in the address that isn't on the server, one
// the account may only read, and a server that won't say — not all of them a
// wrong password, as they read before.
func TestDAVForbidden(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if u, p, ok := r.BasicAuth(); !ok || u != "me" || p != "pw" {
w.WriteHeader(http.StatusUnauthorized)
return
}
path := strings.TrimSuffix(r.URL.Path, "/")
switch {
case r.Method == "PROPFIND" && (path == "" || path == "/ro"):
w.WriteHeader(http.StatusMultiStatus)
case r.Method == "PROPFIND" && path == "/missing":
w.WriteHeader(http.StatusNotFound)
case r.Method == http.MethodGet && strings.HasPrefix(path, "/ro/"):
w.WriteHeader(http.StatusNotFound)
default: // a Synology: anything else, 403
w.WriteHeader(http.StatusForbidden)
}
}))
defer srv.Close()
ctx := context.Background()
for _, c := range []struct{ dir, pass, want string }{
{"/missing", "pw", "there is no folder /missing"},
{"/ro", "pw", "doesn't let this account write in /ro"},
{"/secret", "pw", "doesn't let this account use /secret"},
{"/missing", "bad", "user name or password"},
} {
d, err := newDAV(Config{URL: srv.URL + c.dir, User: "me", Password: c.pass})
if err != nil {
t.Fatal(err)
}
_, _, err = d.get(ctx, version{})
if err == nil {
_, err = d.put(ctx, []byte("x"), "")
}
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: %v, want %q", c.dir, err, c.want)
}
}
}
// OpenList's and Alist's /dav/ lists their storages: a folder can't be made
// there (MKCOL 405, as if it were) and a file can't be put (404). The error
// says to put a storage in the address, naming them.
func TestDAVStorageRoot(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "PROPFIND" && r.URL.Path == "/dav/" && r.Header.Get("Depth") == "1":
w.WriteHeader(http.StatusMultiStatus)
io.WriteString(w, `<?xml version="1.0" encoding="UTF-8"?><D:multistatus xmlns:D="DAV:">`+
`<D:response><D:href>/dav/</D:href><D:propstat><D:prop><D:resourcetype><D:collection/></D:resourcetype></D:prop></D:propstat></D:response>`+
`<D:response><D:href>/dav/local/</D:href><D:propstat><D:prop><D:resourcetype><D:collection/></D:resourcetype></D:prop></D:propstat></D:response>`+
`<D:response><D:href>/dav/aliyun/</D:href><D:propstat><D:prop><D:resourcetype><D:collection/></D:resourcetype></D:prop></D:propstat></D:response>`+
`<D:response><D:href>/dav/readme.txt</D:href><D:propstat><D:prop><D:resourcetype/></D:prop></D:propstat></D:response>`+
`</D:multistatus>`)
case r.Method == "MKCOL":
w.WriteHeader(http.StatusMethodNotAllowed)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
d, err := newDAV(Config{URL: srv.URL + "/dav/"})
if err != nil {
t.Fatal(err)
}
_, err = d.put(context.Background(), []byte("x"), "")
if err == nil || !strings.Contains(err.Error(), "(local, aliyun), like "+srv.URL+"/dav/local") {
t.Fatalf("%v", err)
}
}
// A Synology answers 403 for anything under a folder that isn't there yet,
// PROPFIND too: magpie's own folder, before the first sync, is made rather
// than taken for a folder the account can't write in.
func TestDAVForbiddenUntilMade(t *testing.T) {
made, stored := false, ""
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimSuffix(r.URL.Path, "/")
switch {
case path == "/data" && r.Method == "PROPFIND":
w.WriteHeader(http.StatusMultiStatus)
case path == "/data/magpie" && r.Method == "MKCOL":
made = true
w.WriteHeader(http.StatusCreated)
case strings.HasPrefix(path, "/data/magpie") && made:
switch r.Method {
case "PROPFIND":
w.WriteHeader(http.StatusMultiStatus)
case http.MethodPut:
b, _ := io.ReadAll(r.Body)
stored = string(b)
w.WriteHeader(http.StatusCreated)
case http.MethodGet:
if stored == "" {
w.WriteHeader(http.StatusNotFound)
return
}
io.WriteString(w, stored)
}
default:
w.WriteHeader(http.StatusForbidden)
}
}))
defer srv.Close()
ctx := context.Background()
d, err := newDAV(Config{URL: srv.URL + "/data"})
if err != nil {
t.Fatal(err)
}
if data, _, err := d.get(ctx, version{}); err != nil || data != nil {
t.Fatalf("before the first sync: %q %v", data, err)
}
if _, err := d.put(ctx, []byte("x"), ""); err != nil {
t.Fatal(err)
}
if data, _, err := d.get(ctx, version{}); err != nil || string(data) != "x" {
t.Fatalf("after: %q %v", data, err)
}
}
// A relay or tunnel can cut a long write short: the server keeps half the
// file and answers 2xx anyway, and every later read trips over the half as
// "not a magpie backup". A write that kept less than was sent is read back,
// tried again over the version the short one made, and told as itself in
// the end. A HEAD that can't be done leaves the write unchecked: it landed.
func TestDAVShortWrite(t *testing.T) {
data := []byte(strings.Repeat("x", 8192))
// cut is a small WebDAV with one file and, when etags, an ETag it
// changes on every write and If-Match it holds writes to — so a retry
// on the version read before the short one would be refused. The cut
// write keeps half: the writes-th one, or every one at -1. headfail
// answers the HEAD a check needs with 503.
cut := func(cut int, etags, headfail bool) (*dav, *int) {
var kept []byte
var etag, writes int
etagOf := func() string {
if !etags {
return ""
}
return strconv.Quote("v" + strconv.Itoa(etag))
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if u, p, ok := r.BasicAuth(); !ok || u != "me" || p != "pw" {
w.WriteHeader(http.StatusUnauthorized)
return
}
switch r.Method {
case http.MethodPut:
if im := r.Header.Get("If-Match"); im != "" && im != etagOf() {
w.WriteHeader(http.StatusPreconditionFailed)
return
}
body, _ := io.ReadAll(r.Body)
writes++
if cut == -1 || cut == writes {
kept = body[:len(body)/2] // the tunnel drops the rest
} else {
kept = body
}
etag++
if e := etagOf(); e != "" {
w.Header().Set("ETag", e)
}
w.WriteHeader(http.StatusCreated)
case http.MethodGet:
if e := etagOf(); e != "" {
w.Header().Set("ETag", e)
}
w.Write(kept)
case http.MethodHead:
if headfail {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Length", strconv.Itoa(len(kept)))
if e := etagOf(); e != "" {
w.Header().Set("ETag", e)
}
default:
w.WriteHeader(http.StatusForbidden)
}
}))
t.Cleanup(srv.Close)
d, err := newDAV(Config{URL: srv.URL, User: "me", Password: "pw"})
if err != nil {
t.Fatal(err)
}
return d, &writes
}
ctx := context.Background()
kept := func(d *dav) []byte {
got, _, err := d.get(ctx, version{})
if err != nil {
t.Fatal(err)
}
return got
}
// the write after a cut one is kept whole, so the sync goes through,
// and the server ends with the file in full
d, writes := cut(1, true, false)
if _, err := d.put(ctx, data, ""); err != nil {
t.Fatalf("the write after a short one: %v", err)
}
if *writes != 2 {
t.Fatalf("writes: %d, want 2: the short one was to be tried again", *writes)
}
if got := kept(d); len(got) != len(data) {
t.Fatalf("the server kept %d bytes, want the whole %d", len(got), len(data))
}
// the same over a version read before: the short write changed it, so
// the retry matches what the short write left, not that one
d, writes = cut(2, true, false)
if _, err := d.put(ctx, data, ""); err != nil {
t.Fatalf("the seed write: %v", err)
}
_, v, err := d.get(ctx, version{})
if err != nil {
t.Fatal(err)
}
if v.ETag == "" {
t.Fatal("no ETag to write over")
}
if _, err := d.put(ctx, data, v.ETag); err != nil {
t.Fatalf("over a version read: %v", err)
}
if *writes != 3 {
t.Fatalf("writes: %d, want 3: the short one was to be tried again", *writes)
}
if got := kept(d); len(got) != len(data) {
t.Fatalf("the server kept %d bytes, want the whole %d", len(got), len(data))
}
// a server that keeps cutting: three tries, then the error says what
// was kept
d, writes = cut(-1, true, false)
_, err = d.put(ctx, data, "")
if err == nil || !strings.Contains(err.Error(), "cut short") || !strings.Contains(err.Error(), "4096 of 8192") {
t.Fatalf("always short: %v, want a kept-of-sent count", err)
}
if *writes != 3 {
t.Fatalf("writes: %d, want 3 before giving up", *writes)
}
// a HEAD that can't be done (here 503) leaves the write unchecked:
// it landed, and it isn't failed over the check
d, writes = cut(0, true, true)
if _, err := d.put(ctx, data, ""); err != nil {
t.Fatalf("a write the HEAD couldn't be done for: %v", err)
}
if *writes != 1 {
t.Fatalf("writes: %d, want 1: the write landed", *writes)
}
}