go.net/webdav: new Handler, FileSystem, LockSystem and lockInfo types.

LGTM=dave
R=nmvc, dave
CC=bradfitz, dr.volker.dobler, golang-codereviews
https://golang.org/cl/169240043
diff --git a/webdav/file.go b/webdav/file.go
new file mode 100644
index 0000000..a2953ae
--- /dev/null
+++ b/webdav/file.go
@@ -0,0 +1,28 @@
+// 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 (
+	"io"
+	"net/http"
+	"os"
+)
+
+// TODO: comment that paths are always "/"-separated, even for Windows servers.
+
+type FileSystem interface {
+	Mkdir(path string, perm os.FileMode) error
+	OpenFile(path string, flag int, perm os.FileMode) (File, error)
+	RemoveAll(path string) error
+	Stat(path string) (os.FileInfo, error)
+}
+
+type File interface {
+	http.File
+	io.Writer
+}
+
+// TODO: a MemFS implementation.
+// TODO: a RealFS implementation, backed by the real, OS-provided file system.
diff --git a/webdav/if.go b/webdav/if.go
index e4d4670..416e81c 100644
--- a/webdav/if.go
+++ b/webdav/if.go
@@ -16,18 +16,10 @@
 	lists []ifList
 }
 
-// ifList is a conjunction (AND) of ifConditions, and an optional resource tag.
+// ifList is a conjunction (AND) of Conditions, and an optional resource tag.
 type ifList struct {
 	resourceTag string
-	conditions  []ifCondition
-}
-
-// ifCondition can match a WebDAV resource, based on a stateToken or ETag.
-// Exactly one of stateToken and entityTag should be non-empty.
-type ifCondition struct {
-	not        bool
-	stateToken string
-	entityTag  string
+	conditions  []Condition
 }
 
 // parseIfHeader parses the "If: foo bar" HTTP header. The httpHeader string
@@ -110,19 +102,19 @@
 	}
 }
 
-func parseCondition(s string) (c ifCondition, remaining string, ok bool) {
+func parseCondition(s string) (c Condition, remaining string, ok bool) {
 	tokenType, tokenStr, s := lex(s)
 	if tokenType == notTokenType {
-		c.not = true
+		c.Not = true
 		tokenType, tokenStr, s = lex(s)
 	}
 	switch tokenType {
 	case strTokenType, angleTokenType:
-		c.stateToken = tokenStr
+		c.Token = tokenStr
 	case squareTokenType:
-		c.entityTag = tokenStr
+		c.ETag = tokenStr
 	default:
-		return ifCondition{}, "", false
+		return Condition{}, "", false
 	}
 	return c, s, true
 }
diff --git a/webdav/if_test.go b/webdav/if_test.go
index 684e8b3..aad61a4 100644
--- a/webdav/if_test.go
+++ b/webdav/if_test.go
@@ -38,7 +38,7 @@
 		`<foo>`,
 		ifHeader{},
 	}, {
-		"bad: no list after resource #1",
+		"bad: no list after resource #2",
 		`<foo> <bar> (a)`,
 		ifHeader{},
 	}, {
@@ -66,12 +66,12 @@
 		`(Not Not a)`,
 		ifHeader{},
 	}, {
-		"good: one list with a stateToken",
+		"good: one list with a Token",
 		`(a)`,
 		ifHeader{
 			lists: []ifList{{
-				conditions: []ifCondition{{
-					stateToken: `a`,
+				conditions: []Condition{{
+					Token: `a`,
 				}},
 			}},
 		},
@@ -80,8 +80,8 @@
 		`([a])`,
 		ifHeader{
 			lists: []ifList{{
-				conditions: []ifCondition{{
-					entityTag: `a`,
+				conditions: []Condition{{
+					ETag: `a`,
 				}},
 			}},
 		},
@@ -90,15 +90,15 @@
 		`(Not a Not b Not [d])`,
 		ifHeader{
 			lists: []ifList{{
-				conditions: []ifCondition{{
-					not:        true,
-					stateToken: `a`,
+				conditions: []Condition{{
+					Not:   true,
+					Token: `a`,
 				}, {
-					not:        true,
-					stateToken: `b`,
+					Not:   true,
+					Token: `b`,
 				}, {
-					not:       true,
-					entityTag: `d`,
+					Not:  true,
+					ETag: `d`,
 				}},
 			}},
 		},
@@ -107,12 +107,12 @@
 		`(a) (b)`,
 		ifHeader{
 			lists: []ifList{{
-				conditions: []ifCondition{{
-					stateToken: `a`,
+				conditions: []Condition{{
+					Token: `a`,
 				}},
 			}, {
-				conditions: []ifCondition{{
-					stateToken: `b`,
+				conditions: []Condition{{
+					Token: `b`,
 				}},
 			}},
 		},
@@ -121,14 +121,14 @@
 		`(Not a) (Not b)`,
 		ifHeader{
 			lists: []ifList{{
-				conditions: []ifCondition{{
-					not:        true,
-					stateToken: `a`,
+				conditions: []Condition{{
+					Not:   true,
+					Token: `a`,
 				}},
 			}, {
-				conditions: []ifCondition{{
-					not:        true,
-					stateToken: `b`,
+				conditions: []Condition{{
+					Not:   true,
+					Token: `b`,
 				}},
 			}},
 		},
@@ -139,8 +139,8 @@
 		ifHeader{
 			lists: []ifList{{
 				resourceTag: `http://www.example.com/users/f/fielding/index.html`,
-				conditions: []ifCondition{{
-					stateToken: `urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6`,
+				conditions: []Condition{{
+					Token: `urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6`,
 				}},
 			}},
 		},
@@ -149,8 +149,8 @@
 		`(<urn:uuid:150852e2-3847-42d5-8cbe-0f4f296f26cf>)`,
 		ifHeader{
 			lists: []ifList{{
-				conditions: []ifCondition{{
-					stateToken: `urn:uuid:150852e2-3847-42d5-8cbe-0f4f296f26cf`,
+				conditions: []Condition{{
+					Token: `urn:uuid:150852e2-3847-42d5-8cbe-0f4f296f26cf`,
 				}},
 			}},
 		},
@@ -161,8 +161,8 @@
 		ifHeader{
 			lists: []ifList{{
 				resourceTag: `http://example.com/locked/`,
-				conditions: []ifCondition{{
-					stateToken: `urn:uuid:150852e2-3847-42d5-8cbe-0f4f296f26cf`,
+				conditions: []Condition{{
+					Token: `urn:uuid:150852e2-3847-42d5-8cbe-0f4f296f26cf`,
 				}},
 			}},
 		},
@@ -173,8 +173,8 @@
 		ifHeader{
 			lists: []ifList{{
 				resourceTag: `http://example.com/locked/member`,
-				conditions: []ifCondition{{
-					stateToken: `urn:uuid:150852e2-3847-42d5-8cbe-0f4f296f26cf`,
+				conditions: []Condition{{
+					Token: `urn:uuid:150852e2-3847-42d5-8cbe-0f4f296f26cf`,
 				}},
 			}},
 		},
@@ -184,12 +184,12 @@
 			(<urn:uuid:e454f3f3-acdc-452a-56c7-00a5c91e4b77>)`,
 		ifHeader{
 			lists: []ifList{{
-				conditions: []ifCondition{{
-					stateToken: `urn:uuid:fe184f2e-6eec-41d0-c765-01adc56e6bb4`,
+				conditions: []Condition{{
+					Token: `urn:uuid:fe184f2e-6eec-41d0-c765-01adc56e6bb4`,
 				}},
 			}, {
-				conditions: []ifCondition{{
-					stateToken: `urn:uuid:e454f3f3-acdc-452a-56c7-00a5c91e4b77`,
+				conditions: []Condition{{
+					Token: `urn:uuid:e454f3f3-acdc-452a-56c7-00a5c91e4b77`,
 				}},
 			}},
 		},
@@ -198,8 +198,8 @@
 		`(<urn:uuid:e71d4fae-5dec-22d6-fea5-00a0c91e6be4>)`,
 		ifHeader{
 			lists: []ifList{{
-				conditions: []ifCondition{{
-					stateToken: `urn:uuid:e71d4fae-5dec-22d6-fea5-00a0c91e6be4`,
+				conditions: []Condition{{
+					Token: `urn:uuid:e71d4fae-5dec-22d6-fea5-00a0c91e6be4`,
 				}},
 			}},
 		},
@@ -210,14 +210,14 @@
 			(["I am another ETag"])`,
 		ifHeader{
 			lists: []ifList{{
-				conditions: []ifCondition{{
-					stateToken: `urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2`,
+				conditions: []Condition{{
+					Token: `urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2`,
 				}, {
-					entityTag: `"I am an ETag"`,
+					ETag: `"I am an ETag"`,
 				}},
 			}, {
-				conditions: []ifCondition{{
-					entityTag: `"I am another ETag"`,
+				conditions: []Condition{{
+					ETag: `"I am another ETag"`,
 				}},
 			}},
 		},
@@ -227,11 +227,11 @@
 			<urn:uuid:58f202ac-22cf-11d1-b12d-002035b29092>)`,
 		ifHeader{
 			lists: []ifList{{
-				conditions: []ifCondition{{
-					not:        true,
-					stateToken: `urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2`,
+				conditions: []Condition{{
+					Not:   true,
+					Token: `urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2`,
 				}, {
-					stateToken: `urn:uuid:58f202ac-22cf-11d1-b12d-002035b29092`,
+					Token: `urn:uuid:58f202ac-22cf-11d1-b12d-002035b29092`,
 				}},
 			}},
 		},
@@ -241,13 +241,13 @@
 			(Not <DAV:no-lock>)`,
 		ifHeader{
 			lists: []ifList{{
-				conditions: []ifCondition{{
-					stateToken: `urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2`,
+				conditions: []Condition{{
+					Token: `urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2`,
 				}},
 			}, {
-				conditions: []ifCondition{{
-					not:        true,
-					stateToken: `DAV:no-lock`,
+				conditions: []Condition{{
+					Not:   true,
+					Token: `DAV:no-lock`,
 				}},
 			}},
 		},
@@ -259,15 +259,15 @@
 		ifHeader{
 			lists: []ifList{{
 				resourceTag: `/resource1`,
-				conditions: []ifCondition{{
-					stateToken: `urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2`,
+				conditions: []Condition{{
+					Token: `urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2`,
 				}, {
-					entityTag: `W/"A weak ETag"`,
+					ETag: `W/"A weak ETag"`,
 				}},
 			}, {
 				resourceTag: `/resource1`,
-				conditions: []ifCondition{{
-					entityTag: `"strong ETag"`,
+				conditions: []Condition{{
+					ETag: `"strong ETag"`,
 				}},
 			}},
 		},
@@ -278,8 +278,8 @@
 		ifHeader{
 			lists: []ifList{{
 				resourceTag: `http://www.example.com/specs/`,
-				conditions: []ifCondition{{
-					stateToken: `urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2`,
+				conditions: []Condition{{
+					Token: `urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2`,
 				}},
 			}},
 		},
@@ -289,8 +289,8 @@
 		ifHeader{
 			lists: []ifList{{
 				resourceTag: `/specs/rfc2518.doc`,
-				conditions: []ifCondition{{
-					entityTag: `"4217"`,
+				conditions: []Condition{{
+					ETag: `"4217"`,
 				}},
 			}},
 		},
@@ -300,9 +300,9 @@
 		ifHeader{
 			lists: []ifList{{
 				resourceTag: `/specs/rfc2518.doc`,
-				conditions: []ifCondition{{
-					not:       true,
-					entityTag: `"4217"`,
+				conditions: []Condition{{
+					Not:  true,
+					ETag: `"4217"`,
 				}},
 			}},
 		},
diff --git a/webdav/lock.go b/webdav/lock.go
new file mode 100644
index 0000000..6538ded
--- /dev/null
+++ b/webdav/lock.go
@@ -0,0 +1,44 @@
+// 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 (
+	"errors"
+	"io"
+	"time"
+)
+
+var (
+	ErrConfirmationFailed = errors.New("webdav: confirmation failed")
+	ErrForbidden          = errors.New("webdav: forbidden")
+	ErrNoSuchLock         = errors.New("webdav: no such lock")
+)
+
+// Condition can match a WebDAV resource, based on a token or ETag.
+// Exactly one of Token and ETag should be non-empty.
+type Condition struct {
+	Not   bool
+	Token string
+	ETag  string
+}
+
+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
+}
+
+type LockDetails struct {
+	Depth    int           // Negative means infinite depth.
+	Duration time.Duration // Negative means unlimited duration.
+	OwnerXML string        // Verbatim XML.
+	Path     string
+}
+
+// TODO: a MemLS implementation.
diff --git a/webdav/webdav.go b/webdav/webdav.go
new file mode 100644
index 0000000..7ffb859
--- /dev/null
+++ b/webdav/webdav.go
@@ -0,0 +1,295 @@
+// 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 etc etc TODO.
+package webdav
+
+// TODO: ETag, properties.
+// TODO: figure out what/when is responsible for path cleaning: no "../../etc/passwd"s.
+
+import (
+	"errors"
+	"io"
+	"net/http"
+	"os"
+	"time"
+)
+
+// TODO: define the PropSystem interface.
+type PropSystem interface{}
+
+type Handler struct {
+	// FileSystem is the virtual file system.
+	FileSystem FileSystem
+	// LockSystem is the lock management system.
+	LockSystem LockSystem
+	// PropSystem is an optional property management system. If non-nil, TODO.
+	PropSystem PropSystem
+	// Logger is an optional error logger. If non-nil, it will be called
+	// whenever handling a http.Request results in an error.
+	Logger func(*http.Request, error)
+}
+
+func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	status, err := http.StatusBadRequest, error(nil)
+	if h.FileSystem == nil {
+		status, err = http.StatusInternalServerError, errNoFileSystem
+	} else if h.LockSystem == nil {
+		status, err = http.StatusInternalServerError, errNoLockSystem
+	} else {
+		// TODO: COPY, MOVE, PROPFIND, PROPPATCH methods. Also, OPTIONS??
+		switch r.Method {
+		case "GET", "HEAD", "POST":
+			status, err = h.handleGetHeadPost(w, r)
+		case "DELETE":
+			status, err = h.handleDelete(w, r)
+		case "PUT":
+			status, err = h.handlePut(w, r)
+		case "MKCOL":
+			status, err = h.handleMkcol(w, r)
+		case "LOCK":
+			status, err = h.handleLock(w, r)
+		case "UNLOCK":
+			status, err = h.handleUnlock(w, r)
+		}
+	}
+
+	if status != 0 {
+		w.WriteHeader(status)
+		if status != http.StatusNoContent {
+			w.Write([]byte(StatusText(status)))
+		}
+	}
+	if h.Logger != nil && err != nil {
+		h.Logger(r, err)
+	}
+}
+
+func (h *Handler) confirmLocks(r *http.Request) (closer io.Closer, status int, err error) {
+	ih, ok := parseIfHeader(r.Header.Get("If"))
+	if !ok {
+		return nil, http.StatusBadRequest, errInvalidIfHeader
+	}
+	// ih is a disjunction (OR) of ifLists, so any ifList will do.
+	for _, l := range ih.lists {
+		path := l.resourceTag
+		if path == "" {
+			path = r.URL.Path
+		}
+		closer, err = h.LockSystem.Confirm(path, l.conditions...)
+		if err == ErrConfirmationFailed {
+			continue
+		}
+		if err != nil {
+			return nil, http.StatusInternalServerError, err
+		}
+		return closer, 0, nil
+	}
+	return nil, http.StatusPreconditionFailed, errLocked
+}
+
+func (h *Handler) handleGetHeadPost(w http.ResponseWriter, r *http.Request) (status int, err error) {
+	// TODO: check locks for read-only access??
+	f, err := h.FileSystem.OpenFile(r.URL.Path, os.O_RDONLY, 0)
+	if err != nil {
+		return http.StatusNotFound, err
+	}
+	defer f.Close()
+	fi, err := f.Stat()
+	if err != nil {
+		return http.StatusNotFound, err
+	}
+	http.ServeContent(w, r, r.URL.Path, fi.ModTime(), f)
+	return 0, nil
+}
+
+func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) (status int, err error) {
+	closer, status, err := h.confirmLocks(r)
+	if err != nil {
+		return status, err
+	}
+	defer closer.Close()
+
+	if err := h.FileSystem.RemoveAll(r.URL.Path); err != nil {
+		// TODO: MultiStatus.
+		return http.StatusMethodNotAllowed, err
+	}
+	return http.StatusNoContent, nil
+}
+
+func (h *Handler) handlePut(w http.ResponseWriter, r *http.Request) (status int, err error) {
+	closer, status, err := h.confirmLocks(r)
+	if err != nil {
+		return status, err
+	}
+	defer closer.Close()
+
+	f, err := h.FileSystem.OpenFile(r.URL.Path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666)
+	if err != nil {
+		return http.StatusNotFound, err
+	}
+	defer f.Close()
+	if _, err := io.Copy(f, r.Body); err != nil {
+		return http.StatusMethodNotAllowed, err
+	}
+	return http.StatusCreated, nil
+}
+
+func (h *Handler) handleMkcol(w http.ResponseWriter, r *http.Request) (status int, err error) {
+	closer, status, err := h.confirmLocks(r)
+	if err != nil {
+		return status, err
+	}
+	defer closer.Close()
+
+	if err := h.FileSystem.Mkdir(r.URL.Path, 0777); err != nil {
+		if os.IsNotExist(err) {
+			return http.StatusConflict, err
+		}
+		return http.StatusMethodNotAllowed, err
+	}
+	return http.StatusCreated, nil
+}
+
+func (h *Handler) handleLock(w http.ResponseWriter, r *http.Request) (retStatus int, retErr error) {
+	duration, err := parseTimeout(r.Header.Get("Timeout"))
+	if err != nil {
+		return http.StatusBadRequest, err
+	}
+	li, status, err := readLockInfo(r.Body)
+	if err != nil {
+		return status, err
+	}
+
+	token, ld := "", LockDetails{}
+	if li == (lockInfo{}) {
+		// An empty lockInfo means to refresh the lock.
+		ih, ok := parseIfHeader(r.Header.Get("If"))
+		if !ok {
+			return http.StatusBadRequest, errInvalidIfHeader
+		}
+		if len(ih.lists) == 1 && len(ih.lists[0].conditions) == 1 {
+			token = ih.lists[0].conditions[0].Token
+		}
+		if token == "" {
+			return http.StatusBadRequest, errInvalidLockToken
+		}
+		var closer io.Closer
+		ld, closer, err = h.LockSystem.Refresh(token, time.Now(), 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
+		}
+		ld = LockDetails{
+			Depth:    depth,
+			Duration: duration,
+			OwnerXML: li.Owner.InnerXML,
+			Path:     r.URL.Path,
+		}
+		var closer io.Closer
+		token, closer, err = h.LockSystem.Create(r.URL.Path, time.Now(), ld)
+		if err != nil {
+			return http.StatusInternalServerError, err
+		}
+		defer func() {
+			if retErr != nil {
+				h.LockSystem.Unlock(token)
+			}
+		}()
+		defer closer.Close()
+
+		// Create the resource if it didn't previously exist.
+		if _, err := h.FileSystem.Stat(r.URL.Path); err != nil {
+			f, err := h.FileSystem.OpenFile(r.URL.Path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666)
+			if err != nil {
+				// TODO: detect missing intermediate dirs and return http.StatusConflict?
+				return http.StatusInternalServerError, err
+			}
+			f.Close()
+			w.WriteHeader(http.StatusCreated)
+			// http://www.webdav.org/specs/rfc4918.html#HEADER_Lock-Token says that the
+			// Lock-Token value is a Coded-URL. We add angle brackets.
+			w.Header().Set("Lock-Token", "<"+token+">")
+		}
+	}
+
+	w.Header().Set("Content-Type", "application/xml; charset=utf-8")
+	writeLockInfo(w, token, ld)
+	return 0, nil
+}
+
+func (h *Handler) handleUnlock(w http.ResponseWriter, r *http.Request) (status int, err error) {
+	// http://www.webdav.org/specs/rfc4918.html#HEADER_Lock-Token says that the
+	// Lock-Token value is a Coded-URL. We strip its angle brackets.
+	t := r.Header.Get("Lock-Token")
+	if len(t) < 2 || t[0] != '<' || t[len(t)-1] != '>' {
+		return http.StatusBadRequest, errInvalidLockToken
+	}
+	t = t[1 : len(t)-1]
+
+	switch err = h.LockSystem.Unlock(t); err {
+	case nil:
+		return http.StatusNoContent, err
+	case ErrForbidden:
+		return http.StatusForbidden, err
+	case ErrNoSuchLock:
+		return http.StatusConflict, err
+	default:
+		return http.StatusInternalServerError, err
+	}
+}
+
+func parseDepth(s string) (int, error) {
+	// TODO: implement.
+	return -1, nil
+}
+
+func parseTimeout(s string) (time.Duration, error) {
+	// TODO: implement.
+	return 1 * time.Second, nil
+}
+
+// http://www.webdav.org/specs/rfc4918.html#status.code.extensions.to.http11
+const (
+	StatusMulti               = 207
+	StatusUnprocessableEntity = 422
+	StatusLocked              = 423
+	StatusFailedDependency    = 424
+	StatusInsufficientStorage = 507
+)
+
+func StatusText(code int) string {
+	switch code {
+	case StatusMulti:
+		return "Multi-Status"
+	case StatusUnprocessableEntity:
+		return "Unprocessable Entity"
+	case StatusLocked:
+		return "Locked"
+	case StatusFailedDependency:
+		return "Failed Dependency"
+	case StatusInsufficientStorage:
+		return "Insufficient Storage"
+	}
+	return http.StatusText(code)
+}
+
+var (
+	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
new file mode 100644
index 0000000..5939373
--- /dev/null
+++ b/webdav/xml.go
@@ -0,0 +1,96 @@
+// 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
+
+// The XML encoding is covered by Section 14.
+// http://www.webdav.org/specs/rfc4918.html#xml.element.definitions
+
+import (
+	"bytes"
+	"encoding/xml"
+	"fmt"
+	"io"
+	"net/http"
+	"strconv"
+	"time"
+)
+
+// http://www.webdav.org/specs/rfc4918.html#ELEMENT_lockinfo
+type lockInfo struct {
+	XMLName   xml.Name  `xml:"lockinfo"`
+	Exclusive *struct{} `xml:"lockscope>exclusive"`
+	Shared    *struct{} `xml:"lockscope>shared"`
+	Write     *struct{} `xml:"locktype>write"`
+	Owner     owner     `xml:"owner"`
+}
+
+// http://www.webdav.org/specs/rfc4918.html#ELEMENT_owner
+type owner struct {
+	InnerXML string `xml:",innerxml"`
+}
+
+func readLockInfo(r io.Reader) (li lockInfo, status int, err error) {
+	c := &countingReader{r: r}
+	if err = xml.NewDecoder(c).Decode(&li); err != nil {
+		if err == io.EOF {
+			if c.n == 0 {
+				// An empty body means to refresh the lock.
+				// http://www.webdav.org/specs/rfc4918.html#refreshing-locks
+				return lockInfo{}, 0, nil
+			}
+			err = errInvalidLockInfo
+		}
+		return lockInfo{}, http.StatusBadRequest, err
+	}
+	// We only support exclusive (non-shared) write locks. In practice, these are
+	// the only types of locks that seem to matter.
+	if li.Exclusive == nil || li.Shared != nil || li.Write == nil {
+		return lockInfo{}, http.StatusNotImplemented, errUnsupportedLockInfo
+	}
+	return li, 0, nil
+}
+
+type countingReader struct {
+	n int
+	r io.Reader
+}
+
+func (c *countingReader) Read(p []byte) (int, error) {
+	n, err := c.r.Read(p)
+	c.n += n
+	return n, err
+}
+
+func writeLockInfo(w io.Writer, token string, ld LockDetails) (int, error) {
+	depth := "infinity"
+	if d := ld.Depth; d >= 0 {
+		depth = strconv.Itoa(d)
+	}
+	timeout := ld.Duration / time.Second
+	return fmt.Fprintf(w, "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"+
+		"<D:prop xmlns:D=\"DAV:\"><D:lockdiscovery><D:activelock>\n"+
+		"	<D:locktype><D:write/></D:locktype>\n"+
+		"	<D:lockscope><D:exclusive/></D:lockscope>\n"+
+		"	<D:depth>%s</D:depth>\n"+
+		"	<D:owner>%s</D:owner>\n"+
+		"	<D:timeout>Second-%d</D:timeout>\n"+
+		"	<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),
+	)
+}
+
+func escape(s string) string {
+	for i := 0; i < len(s); i++ {
+		switch s[i] {
+		case '"', '&', '\'', '<', '>':
+			b := bytes.NewBuffer(nil)
+			xml.EscapeText(b, []byte(s))
+			return b.String()
+		}
+	}
+	return s
+}
diff --git a/webdav/xml_test.go b/webdav/xml_test.go
new file mode 100644
index 0000000..26149a2
--- /dev/null
+++ b/webdav/xml_test.go
@@ -0,0 +1,129 @@
+// 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 (
+	"encoding/xml"
+	"net/http"
+	"reflect"
+	"strings"
+	"testing"
+)
+
+func TestParseLockInfo(t *testing.T) {
+	// The "section x.y.z" test cases come from section x.y.z of the spec at
+	// http://www.webdav.org/specs/rfc4918.html
+	testCases := []struct {
+		desc       string
+		input      string
+		wantLI     lockInfo
+		wantStatus int
+	}{{
+		"bad: junk",
+		"xxx",
+		lockInfo{},
+		http.StatusBadRequest,
+	}, {
+		"bad: invalid owner XML",
+		"" +
+			"<D:lockinfo xmlns:D='DAV:'>\n" +
+			"  <D:lockscope><D:exclusive/></D:lockscope>\n" +
+			"  <D:locktype><D:write/></D:locktype>\n" +
+			"  <D:owner>\n" +
+			"    <D:href>   no end tag   \n" +
+			"  </D:owner>\n" +
+			"</D:lockinfo>",
+		lockInfo{},
+		http.StatusBadRequest,
+	}, {
+		"bad: invalid UTF-8",
+		"" +
+			"<D:lockinfo xmlns:D='DAV:'>\n" +
+			"  <D:lockscope><D:exclusive/></D:lockscope>\n" +
+			"  <D:locktype><D:write/></D:locktype>\n" +
+			"  <D:owner>\n" +
+			"    <D:href>   \xff   </D:href>\n" +
+			"  </D:owner>\n" +
+			"</D:lockinfo>",
+		lockInfo{},
+		http.StatusBadRequest,
+	}, {
+		"bad: unfinished XML #1",
+		"" +
+			"<D:lockinfo xmlns:D='DAV:'>\n" +
+			"  <D:lockscope><D:exclusive/></D:lockscope>\n" +
+			"  <D:locktype><D:write/></D:locktype>\n",
+		lockInfo{},
+		http.StatusBadRequest,
+	}, {
+		"bad: unfinished XML #2",
+		"" +
+			"<D:lockinfo xmlns:D='DAV:'>\n" +
+			"  <D:lockscope><D:exclusive/></D:lockscope>\n" +
+			"  <D:locktype><D:write/></D:locktype>\n" +
+			"  <D:owner>\n",
+		lockInfo{},
+		http.StatusBadRequest,
+	}, {
+		"good: empty",
+		"",
+		lockInfo{},
+		0,
+	}, {
+		"good: plain-text owner",
+		"" +
+			"<D:lockinfo xmlns:D='DAV:'>\n" +
+			"  <D:lockscope><D:exclusive/></D:lockscope>\n" +
+			"  <D:locktype><D:write/></D:locktype>\n" +
+			"  <D:owner>gopher</D:owner>\n" +
+			"</D:lockinfo>",
+		lockInfo{
+			XMLName:   xml.Name{Space: "DAV:", Local: "lockinfo"},
+			Exclusive: new(struct{}),
+			Write:     new(struct{}),
+			Owner: owner{
+				InnerXML: "gopher",
+			},
+		},
+		0,
+	}, {
+		"section 9.10.7",
+		"" +
+			"<D:lockinfo xmlns:D='DAV:'>\n" +
+			"  <D:lockscope><D:exclusive/></D:lockscope>\n" +
+			"  <D:locktype><D:write/></D:locktype>\n" +
+			"  <D:owner>\n" +
+			"    <D:href>http://example.org/~ejw/contact.html</D:href>\n" +
+			"  </D:owner>\n" +
+			"</D:lockinfo>",
+		lockInfo{
+			XMLName:   xml.Name{Space: "DAV:", Local: "lockinfo"},
+			Exclusive: new(struct{}),
+			Write:     new(struct{}),
+			Owner: owner{
+				InnerXML: "\n    <D:href>http://example.org/~ejw/contact.html</D:href>\n  ",
+			},
+		},
+		0,
+	}}
+
+	for _, tc := range testCases {
+		li, status, err := readLockInfo(strings.NewReader(tc.input))
+		if tc.wantStatus != 0 {
+			if err == nil {
+				t.Errorf("%s: got nil error, want non-nil", tc.desc)
+				continue
+			}
+		} else if err != nil {
+			t.Errorf("%s: %v", tc.desc, err)
+			continue
+		}
+		if !reflect.DeepEqual(li, tc.wantLI) || status != tc.wantStatus {
+			t.Errorf("%s:\ngot  lockInfo=%v, status=%v\nwant lockInfo=%v, status=%v",
+				tc.desc, li, status, tc.wantLI, tc.wantStatus)
+			continue
+		}
+	}
+}