webdav: first cut of a memLS implementation.

Change-Id: If5745dcc1d66fdaa083d4085440b1535aa75e0da
Reviewed-on: https://go-review.googlesource.com/2430
Reviewed-by: Dave Cheney <dave@cheney.net>
diff --git a/webdav/lock.go b/webdav/lock.go
index 6538ded..215d6dd 100644
--- a/webdav/lock.go
+++ b/webdav/lock.go
@@ -6,14 +6,22 @@
 
 import (
 	"errors"
-	"io"
+	"path"
+	"strconv"
+	"strings"
+	"sync"
 	"time"
 )
 
 var (
+	// ErrConfirmationFailed is returned by a LockSystem's Confirm method.
 	ErrConfirmationFailed = errors.New("webdav: confirmation failed")
-	ErrForbidden          = errors.New("webdav: forbidden")
-	ErrNoSuchLock         = errors.New("webdav: no such lock")
+	// ErrForbidden is returned by a LockSystem's Unlock method.
+	ErrForbidden = errors.New("webdav: forbidden")
+	// ErrLocked is returned by a LockSystem's Create, Refresh and Unlock methods.
+	ErrLocked = errors.New("webdav: locked")
+	// ErrNoSuchLock is returned by a LockSystem's Refresh and Unlock methods.
+	ErrNoSuchLock = errors.New("webdav: no such lock")
 )
 
 // Condition can match a WebDAV resource, based on a token or ETag.
@@ -24,21 +32,263 @@
 	ETag  string
 }
 
+// Releaser releases previously confirmed lock claims.
+//
+// Calling Release does not unlock the lock, in the WebDAV UNLOCK sense, but
+// once LockSystem.Confirm has confirmed that a lock claim is valid, that lock
+// cannot be Confirmed again until it has been Released.
+type Releaser interface {
+	Release()
+}
+
+// LockSystem manages access to a collection of named resources. The elements
+// in a lock name are separated by slash ('/', U+002F) characters, regardless
+// of host operating system convention.
 type LockSystem interface {
-	// TODO: comment that the conditions should be ANDed together.
-	Confirm(path string, conditions ...Condition) (c io.Closer, err error)
-	// TODO: comment that token should be an absolute URI as defined by RFC 3986,
-	// Section 4.3. In particular, it should not contain whitespace.
-	Create(path string, now time.Time, ld LockDetails) (token string, c io.Closer, err error)
-	Refresh(token string, now time.Time, duration time.Duration) (ld LockDetails, c io.Closer, err error)
-	Unlock(token string) error
+	// Confirm confirms that the caller can claim all of the locks specified by
+	// the given conditions, and that holding the union of all of those locks
+	// gives exclusive access to the named resource.
+	//
+	// Exactly one of r and err will be non-nil. If r is non-nil, all of the
+	// requested locks are held until r.Release is called.
+	//
+	// If Confirm returns ErrConfirmationFailed then the Handler will continue
+	// to try any other set of locks presented (a WebDAV HTTP request can
+	// present more than one set of locks). If it returns any other non-nil
+	// error, the Handler will write a "500 Internal Server Error" HTTP status.
+	Confirm(now time.Time, name string, conditions ...Condition) (r Releaser, err error)
+
+	// Create creates a lock with the given depth, duration, owner and root
+	// (name). The depth will either be negative (meaning infinite) or zero.
+	//
+	// If Create returns ErrLocked then the Handler will write a "423 Locked"
+	// HTTP status. If it returns any other non-nil error, the Handler will
+	// write a "500 Internal Server Error" HTTP status.
+	//
+	// See http://www.webdav.org/specs/rfc4918.html#rfc.section.9.10.6 for
+	// when to use each error.
+	//
+	// The token returned identifies the created lock. It should be an absolute
+	// URI as defined by RFC 3986, Section 4.3. In particular, it should not
+	// contain whitespace.
+	Create(now time.Time, details LockDetails) (token string, err error)
+
+	// Refresh refreshes the lock with the given token.
+	//
+	// If Refresh returns ErrLocked then the Handler will write a "423 Locked"
+	// HTTP Status. If Refresh returns ErrNoSuchLock then the Handler will write
+	// a "412 Precondition Failed" HTTP Status. If it returns any other non-nil
+	// error, the Handler will write a "500 Internal Server Error" HTTP status.
+	//
+	// See http://www.webdav.org/specs/rfc4918.html#rfc.section.9.10.6 for
+	// when to use each error.
+	Refresh(now time.Time, token string, duration time.Duration) (LockDetails, error)
+
+	// Unlock unlocks the lock with the given token.
+	//
+	// If Unlock returns ErrForbidden then the Handler will write a "403
+	// Forbidden" HTTP Status. If Unlock returns ErrLocked then the Handler
+	// will write a "423 Locked" HTTP status. If Unlock returns ErrNoSuchLock
+	// then the Handler will write a "409 Conflict" HTTP Status. If it returns
+	// any other non-nil error, the Handler will write a "500 Internal Server
+	// Error" HTTP status.
+	//
+	// See http://www.webdav.org/specs/rfc4918.html#rfc.section.9.11.1 for
+	// when to use each error.
+	Unlock(now time.Time, token string) error
 }
 
+// LockDetails are a lock's metadata.
 type LockDetails struct {
-	Depth    int           // Negative means infinite depth.
-	Duration time.Duration // Negative means unlimited duration.
-	OwnerXML string        // Verbatim XML.
-	Path     string
+	// Root is the root resource name being locked. For a zero-depth lock, the
+	// root is the only resource being locked.
+	Root string
+	// Depth is the lock depth. A negative depth means infinite.
+	//
+	// TODO: should depth be restricted to just "0 or infinite" (i.e. change
+	// this field to "Recursive bool") or just "0 or 1 or infinite"? Is
+	// validating that the responsibility of the Handler or the LockSystem
+	// implementations?
+	Depth int
+	// Duration is the lock timeout. A negative duration means infinite.
+	Duration time.Duration
+	// OwnerXML is the verbatim <owner> XML given in a LOCK HTTP request.
+	//
+	// TODO: does the "verbatim" nature play well with XML namespaces?
+	// Does the OwnerXML field need to have more structure? See
+	// https://codereview.appspot.com/175140043/#msg2
+	OwnerXML string
 }
 
-// TODO: a MemLS implementation.
+// NewMemLS returns a new in-memory LockSystem.
+func NewMemLS() LockSystem {
+	return &memLS{
+		byName:  make(map[string]*memLSNode),
+		byToken: make(map[string]*memLSNode),
+		gen:     uint64(time.Now().Unix()),
+	}
+}
+
+type memLS struct {
+	mu      sync.Mutex
+	byName  map[string]*memLSNode
+	byToken map[string]*memLSNode
+	gen     uint64
+}
+
+func (m *memLS) nextToken() string {
+	m.gen++
+	return strconv.FormatUint(m.gen, 10)
+}
+
+func (m *memLS) collectExpiredNodes(now time.Time) {
+	// TODO: implement.
+}
+
+func (m *memLS) Confirm(now time.Time, name string, conditions ...Condition) (Releaser, error) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	m.collectExpiredNodes(now)
+	name = path.Clean("/" + name)
+
+	// TODO: touch n.held.
+	panic("TODO")
+}
+
+func (m *memLS) Create(now time.Time, details LockDetails) (string, error) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	m.collectExpiredNodes(now)
+	name := path.Clean("/" + details.Root)
+
+	if !m.canCreate(name, details.Depth) {
+		return "", ErrLocked
+	}
+	n := m.create(name)
+	n.token = m.nextToken()
+	m.byToken[n.token] = n
+	n.details = details
+	// TODO: set n.expiry.
+	return n.token, nil
+}
+
+func (m *memLS) Refresh(now time.Time, token string, duration time.Duration) (LockDetails, error) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	m.collectExpiredNodes(now)
+
+	n := m.byToken[token]
+	if n == nil {
+		return LockDetails{}, ErrNoSuchLock
+	}
+	if n.held {
+		return LockDetails{}, ErrLocked
+	}
+	n.details.Duration = duration
+	// TODO: update n.expiry.
+	return n.details, nil
+}
+
+func (m *memLS) Unlock(now time.Time, token string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	m.collectExpiredNodes(now)
+
+	n := m.byToken[token]
+	if n == nil {
+		return ErrNoSuchLock
+	}
+	if n.held {
+		return ErrLocked
+	}
+	m.remove(n)
+	return nil
+}
+
+func (m *memLS) canCreate(name string, depth int) bool {
+	return walkToRoot(name, func(name0 string, first bool) bool {
+		n := m.byName[name0]
+		if n == nil {
+			return true
+		}
+		if first {
+			if n.token != "" {
+				// The target node is already locked.
+				return false
+			}
+			if depth < 0 {
+				// The requested lock depth is infinite, and the fact that n exists
+				// (n != nil) means that a descendent of the target node is locked.
+				return false
+			}
+		} else if n.token != "" && n.details.Depth < 0 {
+			// An ancestor of the target node is locked with infinite depth.
+			return false
+		}
+		return true
+	})
+}
+
+func (m *memLS) create(name string) (ret *memLSNode) {
+	walkToRoot(name, func(name0 string, first bool) bool {
+		n := m.byName[name0]
+		if n == nil {
+			n = &memLSNode{
+				details: LockDetails{
+					Root: name0,
+				},
+			}
+			m.byName[name0] = n
+		}
+		n.refCount++
+		if first {
+			ret = n
+		}
+		return true
+	})
+	return ret
+}
+
+func (m *memLS) remove(n *memLSNode) {
+	delete(m.byToken, n.token)
+	n.token = ""
+	walkToRoot(n.details.Root, func(name0 string, first bool) bool {
+		x := m.byName[name0]
+		x.refCount--
+		if x.refCount == 0 {
+			delete(m.byName, name0)
+		}
+		return true
+	})
+}
+
+func walkToRoot(name string, f func(name0 string, first bool) bool) bool {
+	for first := true; ; first = false {
+		if !f(name, first) {
+			return false
+		}
+		if name == "/" {
+			break
+		}
+		name = name[:strings.LastIndex(name, "/")]
+		if name == "" {
+			name = "/"
+		}
+	}
+	return true
+}
+
+type memLSNode struct {
+	// details are the lock metadata. Even if this node's name is not explicitly locked,
+	// details.Root will still equal the node's name.
+	details LockDetails
+	// token is the unique identifier for this node's lock. An empty token means that
+	// this node is not explicitly locked.
+	token string
+	// refCount is the number of self-or-descendent nodes that are explicitly locked.
+	refCount int
+	// expiry is when this node's lock expires.
+	expiry time.Time
+	// held is whether this node's lock is actively held by a Confirm call.
+	held bool
+}
diff --git a/webdav/lock_test.go b/webdav/lock_test.go
new file mode 100644
index 0000000..090314b
--- /dev/null
+++ b/webdav/lock_test.go
@@ -0,0 +1,253 @@
+// Copyright 2014 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package webdav
+
+import (
+	"fmt"
+	"math/rand"
+	"path"
+	"reflect"
+	"sort"
+	"strings"
+	"testing"
+	"time"
+)
+
+func TestWalkToRoot(t *testing.T) {
+	testCases := []struct {
+		name string
+		want []string
+	}{{
+		"/a/b/c/d",
+		[]string{
+			"/a/b/c/d",
+			"/a/b/c",
+			"/a/b",
+			"/a",
+			"/",
+		},
+	}, {
+		"/a",
+		[]string{
+			"/a",
+			"/",
+		},
+	}, {
+		"/",
+		[]string{
+			"/",
+		},
+	}}
+
+	for _, tc := range testCases {
+		var got []string
+		if !walkToRoot(tc.name, func(name0 string, first bool) bool {
+			if first != (len(got) == 0) {
+				t.Errorf("name=%q: first=%t but len(got)==%d", tc.name, first, len(got))
+				return false
+			}
+			got = append(got, name0)
+			return true
+		}) {
+			continue
+		}
+		if !reflect.DeepEqual(got, tc.want) {
+			t.Errorf("name=%q:\ngot  %q\nwant %q", tc.name, got, tc.want)
+		}
+	}
+}
+
+// lockTestNames are the names of a set of mutually compatible locks. For each
+// name fragment:
+//	- _ means no explicit lock.
+//	- i means a infinite-depth lock,
+//	- z means a zero-depth lock,
+var lockTestNames = []string{
+	"/_/_/_/_/z",
+	"/_/_/i",
+	"/_/z",
+	"/_/z/i",
+	"/_/z/z",
+	"/_/z/_/i",
+	"/_/z/_/z",
+	"/i",
+	"/z",
+	"/z/_/i",
+	"/z/_/z",
+}
+
+func lockTestDepth(name string) int {
+	switch name[len(name)-1] {
+	case 'i':
+		return -1
+	case 'z':
+		return 0
+	}
+	panic(fmt.Sprintf("lock name %q did not end with 'i' or 'z'", name))
+}
+
+func TestMemLSCanCreate(t *testing.T) {
+	now := time.Unix(0, 0)
+	m := NewMemLS().(*memLS)
+
+	for _, name := range lockTestNames {
+		_, err := m.Create(now, LockDetails{
+			Depth:    lockTestDepth(name),
+			Duration: -1,
+			Root:     name,
+		})
+		if err != nil {
+			t.Fatalf("creating lock for %q: %v", name, err)
+		}
+	}
+
+	wantCanCreate := func(name string, depth int) bool {
+		for _, n := range lockTestNames {
+			switch {
+			case n == name:
+				// An existing lock has the same name as the proposed lock.
+				return false
+			case strings.HasPrefix(n, name):
+				// An existing lock would be a child of the proposed lock,
+				// which conflicts if the proposed lock has infinite depth.
+				if depth < 0 {
+					return false
+				}
+			case strings.HasPrefix(name, n):
+				// An existing lock would be an ancestor of the proposed lock,
+				// which conflicts if the ancestor has infinite depth.
+				if n[len(n)-1] == 'i' {
+					return false
+				}
+			}
+		}
+		return true
+	}
+
+	var check func(int, string)
+	check = func(recursion int, name string) {
+		for _, depth := range []int{-1, 0} {
+			got := m.canCreate(name, depth)
+			want := wantCanCreate(name, depth)
+			if got != want {
+				t.Errorf("canCreate name=%q depth=%d: got %t, want %t", name, depth, got, want)
+			}
+		}
+		if recursion == 6 {
+			return
+		}
+		if name != "/" {
+			name += "/"
+		}
+		for _, c := range "_iz" {
+			check(recursion+1, name+string(c))
+		}
+	}
+	check(0, "/")
+}
+
+func TestMemLSCreateUnlock(t *testing.T) {
+	now := time.Unix(0, 0)
+	m := NewMemLS().(*memLS)
+	rng := rand.New(rand.NewSource(0))
+	tokens := map[string]string{}
+	for i := 0; i < 1000; i++ {
+		name := lockTestNames[rng.Intn(len(lockTestNames))]
+		if token := tokens[name]; token != "" {
+			if err := m.Unlock(now, token); err != nil {
+				t.Fatalf("iteration #%d: unlock %q: %v", i, name, err)
+			}
+			tokens[name] = ""
+		} else {
+			token, err := m.Create(now, LockDetails{
+				Depth:    lockTestDepth(name),
+				Duration: -1,
+				Root:     name,
+			})
+			if err != nil {
+				t.Fatalf("iteration #%d: create %q: %v", i, name, err)
+			}
+			tokens[name] = token
+		}
+		if err := m.consistent(); err != nil {
+			t.Fatalf("iteration #%d: inconsistent state: %v", i, err)
+		}
+	}
+}
+
+func (m *memLS) consistent() error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	// If m.byName is non-empty, then it must contain an entry for the root "/",
+	// and its refCount should equal the number of locked nodes.
+	if len(m.byName) > 0 {
+		n := m.byName["/"]
+		if n == nil {
+			return fmt.Errorf(`non-empty m.byName does not contain the root "/"`)
+		}
+		if n.refCount != len(m.byToken) {
+			return fmt.Errorf("root node refCount=%d, differs from len(m.byToken)=%d", n.refCount, len(m.byToken))
+		}
+	}
+
+	for name, n := range m.byName {
+		// The map keys should be consistent with the node's copy of the key.
+		if n.details.Root != name {
+			return fmt.Errorf("node name %q != byName map key %q", n.details.Root, name)
+		}
+
+		// A name must be clean, and start with a "/".
+		if len(name) == 0 || name[0] != '/' {
+			return fmt.Errorf(`node name %q does not start with "/"`, name)
+		}
+		if name != path.Clean(name) {
+			return fmt.Errorf(`node name %q is not clean`, name)
+		}
+
+		// A node's refCount should be positive.
+		if n.refCount <= 0 {
+			return fmt.Errorf("non-positive refCount for node at name %q", name)
+		}
+
+		// A node's refCount should be the number of self-or-descendents that
+		// are locked (i.e. have a non-empty token).
+		var list []string
+		for name0, n0 := range m.byName {
+			// All of lockTestNames' name fragments are one byte long: '_', 'i' or 'z',
+			// so strings.HasPrefix is equivalent to self-or-descendent name match.
+			// We don't have to worry about "/foo/bar" being a false positive match
+			// for "/foo/b".
+			if strings.HasPrefix(name0, name) && n0.token != "" {
+				list = append(list, name0)
+			}
+		}
+		if n.refCount != len(list) {
+			sort.Strings(list)
+			return fmt.Errorf("node at name %q has refCount %d but locked self-or-descendents are %q (len=%d)",
+				name, n.refCount, list, len(list))
+		}
+
+		// A node n is in m.byToken if it has a non-empty token.
+		if n.token != "" {
+			if _, ok := m.byToken[n.token]; !ok {
+				return fmt.Errorf("node at name %q has token %q but not in m.byToken", name, n.token)
+			}
+		}
+	}
+
+	for token, n := range m.byToken {
+		// The map keys should be consistent with the node's copy of the key.
+		if n.token != token {
+			return fmt.Errorf("node token %q != byToken map key %q", n.token, token)
+		}
+
+		// Every node in m.byToken is in m.byName.
+		if _, ok := m.byName[n.details.Root]; !ok {
+			return fmt.Errorf("node at name %q in m.byToken but not in m.byName", n.details.Root)
+		}
+	}
+	return nil
+}
diff --git a/webdav/webdav.go b/webdav/webdav.go
index 192dc7e..1a31bd0 100644
--- a/webdav/webdav.go
+++ b/webdav/webdav.go
@@ -67,16 +67,14 @@
 	}
 }
 
-type nopCloser struct{}
+type nopReleaser struct{}
 
-func (nopCloser) Close() error {
-	return nil
-}
+func (nopReleaser) Release() {}
 
-func (h *Handler) confirmLocks(r *http.Request) (closer io.Closer, status int, err error) {
+func (h *Handler) confirmLocks(r *http.Request) (releaser Releaser, status int, err error) {
 	hdr := r.Header.Get("If")
 	if hdr == "" {
-		return nopCloser{}, 0, nil
+		return nopReleaser{}, 0, nil
 	}
 	ih, ok := parseIfHeader(hdr)
 	if !ok {
@@ -88,16 +86,16 @@
 		if path == "" {
 			path = r.URL.Path
 		}
-		closer, err = h.LockSystem.Confirm(path, l.conditions...)
+		releaser, err = h.LockSystem.Confirm(time.Now(), path, l.conditions...)
 		if err == ErrConfirmationFailed {
 			continue
 		}
 		if err != nil {
 			return nil, http.StatusInternalServerError, err
 		}
-		return closer, 0, nil
+		return releaser, 0, nil
 	}
-	return nil, http.StatusPreconditionFailed, errLocked
+	return nil, http.StatusPreconditionFailed, ErrLocked
 }
 
 func (h *Handler) handleOptions(w http.ResponseWriter, r *http.Request) (status int, err error) {
@@ -134,11 +132,11 @@
 }
 
 func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) (status int, err error) {
-	closer, status, err := h.confirmLocks(r)
+	releaser, status, err := h.confirmLocks(r)
 	if err != nil {
 		return status, err
 	}
-	defer closer.Close()
+	defer releaser.Release()
 
 	if err := h.FileSystem.RemoveAll(r.URL.Path); err != nil {
 		if os.IsNotExist(err) {
@@ -151,11 +149,11 @@
 }
 
 func (h *Handler) handlePut(w http.ResponseWriter, r *http.Request) (status int, err error) {
-	closer, status, err := h.confirmLocks(r)
+	releaser, status, err := h.confirmLocks(r)
 	if err != nil {
 		return status, err
 	}
-	defer closer.Close()
+	defer releaser.Release()
 
 	f, err := h.FileSystem.OpenFile(r.URL.Path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666)
 	if err != nil {
@@ -169,11 +167,11 @@
 }
 
 func (h *Handler) handleMkcol(w http.ResponseWriter, r *http.Request) (status int, err error) {
-	closer, status, err := h.confirmLocks(r)
+	releaser, status, err := h.confirmLocks(r)
 	if err != nil {
 		return status, err
 	}
-	defer closer.Close()
+	defer releaser.Release()
 
 	if r.ContentLength > 0 {
 		return http.StatusUnsupportedMediaType, nil
@@ -197,7 +195,7 @@
 		return status, err
 	}
 
-	token, ld := "", LockDetails{}
+	token, ld, now := "", LockDetails{}, time.Now()
 	if li == (lockInfo{}) {
 		// An empty lockInfo means to refresh the lock.
 		ih, ok := parseIfHeader(r.Header.Get("If"))
@@ -210,38 +208,42 @@
 		if token == "" {
 			return http.StatusBadRequest, errInvalidLockToken
 		}
-		var closer io.Closer
-		ld, closer, err = h.LockSystem.Refresh(token, time.Now(), duration)
+		ld, err = h.LockSystem.Refresh(now, token, duration)
 		if err != nil {
 			if err == ErrNoSuchLock {
 				return http.StatusPreconditionFailed, err
 			}
 			return http.StatusInternalServerError, err
 		}
-		defer closer.Close()
 
 	} else {
 		depth, err := parseDepth(r.Header.Get("Depth"))
 		if err != nil {
 			return http.StatusBadRequest, err
 		}
+		if depth > 0 {
+			// Section 9.10.3 says that "Values other than 0 or infinity must not be
+			// used with the Depth header on a LOCK method".
+			return http.StatusBadRequest, errInvalidDepth
+		}
 		ld = LockDetails{
 			Depth:    depth,
 			Duration: duration,
 			OwnerXML: li.Owner.InnerXML,
-			Path:     r.URL.Path,
+			Root:     r.URL.Path,
 		}
-		var closer io.Closer
-		token, closer, err = h.LockSystem.Create(r.URL.Path, time.Now(), ld)
+		token, err = h.LockSystem.Create(now, ld)
 		if err != nil {
+			if err == ErrLocked {
+				return StatusLocked, err
+			}
 			return http.StatusInternalServerError, err
 		}
 		defer func() {
 			if retErr != nil {
-				h.LockSystem.Unlock(token)
+				h.LockSystem.Unlock(now, token)
 			}
 		}()
-		defer closer.Close()
 
 		// Create the resource if it didn't previously exist.
 		if _, err := h.FileSystem.Stat(r.URL.Path); err != nil {
@@ -272,11 +274,13 @@
 	}
 	t = t[1 : len(t)-1]
 
-	switch err = h.LockSystem.Unlock(t); err {
+	switch err = h.LockSystem.Unlock(time.Now(), t); err {
 	case nil:
 		return http.StatusNoContent, err
 	case ErrForbidden:
 		return http.StatusForbidden, err
+	case ErrLocked:
+		return StatusLocked, err
 	case ErrNoSuchLock:
 		return http.StatusConflict, err
 	default:
@@ -320,10 +324,10 @@
 }
 
 var (
+	errInvalidDepth        = errors.New("webdav: invalid depth")
 	errInvalidIfHeader     = errors.New("webdav: invalid If header")
 	errInvalidLockInfo     = errors.New("webdav: invalid lock info")
 	errInvalidLockToken    = errors.New("webdav: invalid lock token")
-	errLocked              = errors.New("webdav: locked")
 	errNoFileSystem        = errors.New("webdav: no file system")
 	errNoLockSystem        = errors.New("webdav: no lock system")
 	errUnsupportedLockInfo = errors.New("webdav: unsupported lock info")
diff --git a/webdav/xml.go b/webdav/xml.go
index 5939373..c48fc0f 100644
--- a/webdav/xml.go
+++ b/webdav/xml.go
@@ -79,7 +79,7 @@
 		"	<D:locktoken><D:href>%s</D:href></D:locktoken>\n"+
 		"	<D:lockroot><D:href>%s</D:href></D:lockroot>\n"+
 		"</D:activelock></D:lockdiscovery></D:prop>",
-		depth, ld.OwnerXML, timeout, escape(token), escape(ld.Path),
+		depth, ld.OwnerXML, timeout, escape(token), escape(ld.Root),
 	)
 }