webdav: define property system and implement PROPFIND.

This change adds support for PROPFIND requests to net/webdav.
It contains a proposed PropSystem interface and a preliminary
implementation of an in-memory property system. As discussed
with nigeltao, this is the first of approximately 4-5 CLs to
get property support in the net/webdav package.

Current coverage of litmus 'props' test suite:
16 tests were skipped, 14 tests run. 10 passed, 4 failed. 71.4%

Change-Id: I0bc5f375422137e911a2f6fb0e99c43a5a52d5ac
Reviewed-on: https://go-review.googlesource.com/3417
Reviewed-by: Nigel Tao <nigeltao@golang.org>
diff --git a/webdav/file.go b/webdav/file.go
index aa4ea6a..2f62dc4 100644
--- a/webdav/file.go
+++ b/webdav/file.go
@@ -672,3 +672,54 @@
 	}
 	return http.StatusNoContent, nil
 }
+
+// walkFS traverses filesystem fs starting at path up to depth levels.
+//
+// Allowed values for depth are 0, 1 or infiniteDepth. For each visited node,
+// walkFS calls walkFn. If a visited file system node is a directory and
+// walkFn returns filepath.SkipDir, walkFS will skip traversal of this node.
+func walkFS(fs FileSystem, depth int, path string, info os.FileInfo, walkFn filepath.WalkFunc) error {
+	// This implementation is based on Walk's code in the standard path/filepath package.
+	err := walkFn(path, info, nil)
+	if err != nil {
+		if info.IsDir() && err == filepath.SkipDir {
+			return nil
+		}
+		return err
+	}
+	if !info.IsDir() || depth == 0 {
+		return nil
+	}
+	if depth == 1 {
+		depth = 0
+	}
+
+	// Read directory names.
+	f, err := fs.OpenFile(path, os.O_RDONLY, 0)
+	if err != nil {
+		return walkFn(path, info, err)
+	}
+	fileInfos, err := f.Readdir(0)
+	f.Close()
+	if err != nil {
+		return walkFn(path, info, err)
+	}
+
+	for _, fileInfo := range fileInfos {
+		filename := filepath.Join(path, fileInfo.Name())
+		fileInfo, err := fs.Stat(filename)
+		if err != nil {
+			if err := walkFn(filename, fileInfo, err); err != nil && err != filepath.SkipDir {
+				return err
+			}
+		} else {
+			err = walkFS(fs, depth, filename, fileInfo, walkFn)
+			if err != nil {
+				if !fileInfo.IsDir() || err != filepath.SkipDir {
+					return err
+				}
+			}
+		}
+	}
+	return nil
+}
diff --git a/webdav/file_test.go b/webdav/file_test.go
index 0d06b3f..5d327db 100644
--- a/webdav/file_test.go
+++ b/webdav/file_test.go
@@ -823,3 +823,226 @@
 		}
 	}
 }
+
+func TestWalkFS(t *testing.T) {
+	testCases := []struct {
+		desc    string
+		buildfs []string
+		startAt string
+		depth   int
+		walkFn  filepath.WalkFunc
+		want    []string
+	}{{
+		"just root",
+		[]string{},
+		"/",
+		infiniteDepth,
+		nil,
+		[]string{
+			"/",
+		},
+	}, {
+		"infinite walk from root",
+		[]string{
+			"mkdir /a",
+			"mkdir /a/b",
+			"touch /a/b/c",
+			"mkdir /a/d",
+			"mkdir /e",
+			"touch /f",
+		},
+		"/",
+		infiniteDepth,
+		nil,
+		[]string{
+			"/",
+			"/a",
+			"/a/b",
+			"/a/b/c",
+			"/a/d",
+			"/e",
+			"/f",
+		},
+	}, {
+		"infinite walk from subdir",
+		[]string{
+			"mkdir /a",
+			"mkdir /a/b",
+			"touch /a/b/c",
+			"mkdir /a/d",
+			"mkdir /e",
+			"touch /f",
+		},
+		"/a",
+		infiniteDepth,
+		nil,
+		[]string{
+			"/a",
+			"/a/b",
+			"/a/b/c",
+			"/a/d",
+		},
+	}, {
+		"depth 1 walk from root",
+		[]string{
+			"mkdir /a",
+			"mkdir /a/b",
+			"touch /a/b/c",
+			"mkdir /a/d",
+			"mkdir /e",
+			"touch /f",
+		},
+		"/",
+		1,
+		nil,
+		[]string{
+			"/",
+			"/a",
+			"/e",
+			"/f",
+		},
+	}, {
+		"depth 1 walk from subdir",
+		[]string{
+			"mkdir /a",
+			"mkdir /a/b",
+			"touch /a/b/c",
+			"mkdir /a/b/g",
+			"mkdir /a/b/g/h",
+			"touch /a/b/g/i",
+			"touch /a/b/g/h/j",
+		},
+		"/a/b",
+		1,
+		nil,
+		[]string{
+			"/a/b",
+			"/a/b/c",
+			"/a/b/g",
+		},
+	}, {
+		"depth 0 walk from subdir",
+		[]string{
+			"mkdir /a",
+			"mkdir /a/b",
+			"touch /a/b/c",
+			"mkdir /a/b/g",
+			"mkdir /a/b/g/h",
+			"touch /a/b/g/i",
+			"touch /a/b/g/h/j",
+		},
+		"/a/b",
+		0,
+		nil,
+		[]string{
+			"/a/b",
+		},
+	}, {
+		"infinite walk from file",
+		[]string{
+			"mkdir /a",
+			"touch /a/b",
+			"touch /a/c",
+		},
+		"/a/b",
+		0,
+		nil,
+		[]string{
+			"/a/b",
+		},
+	}, {
+		"infinite walk with skipped subdir",
+		[]string{
+			"mkdir /a",
+			"mkdir /a/b",
+			"touch /a/b/c",
+			"mkdir /a/b/g",
+			"mkdir /a/b/g/h",
+			"touch /a/b/g/i",
+			"touch /a/b/g/h/j",
+			"touch /a/b/z",
+		},
+		"/",
+		infiniteDepth,
+		func(path string, info os.FileInfo, err error) error {
+			if path == "/a/b/g" {
+				return filepath.SkipDir
+			}
+			return nil
+		},
+		[]string{
+			"/",
+			"/a",
+			"/a/b",
+			"/a/b/c",
+			"/a/b/z",
+		},
+	}}
+	for _, tc := range testCases {
+		fs, err := buildTestFS(tc.buildfs)
+		if err != nil {
+			t.Fatalf("%s: cannot create test filesystem: %v", tc.desc, err)
+		}
+		var got []string
+		traceFn := func(path string, info os.FileInfo, err error) error {
+			if tc.walkFn != nil {
+				err = tc.walkFn(path, info, err)
+				if err != nil {
+					return err
+				}
+			}
+			got = append(got, path)
+			return nil
+		}
+		fi, err := fs.Stat(tc.startAt)
+		if err != nil {
+			t.Fatalf("%s: cannot stat: %v", tc.desc, err)
+		}
+		err = walkFS(fs, tc.depth, tc.startAt, fi, traceFn)
+		if err != nil {
+			t.Errorf("%s:\ngot error %v, want nil", tc.desc, err)
+			continue
+		}
+		sort.Strings(got)
+		sort.Strings(tc.want)
+		if !reflect.DeepEqual(got, tc.want) {
+			t.Errorf("%s:\ngot  %q\nwant %q", tc.desc, got, tc.want)
+			continue
+		}
+	}
+}
+
+func buildTestFS(buildfs []string) (FileSystem, error) {
+	// TODO: Could this be merged with the build logic in TestFS?
+
+	fs := NewMemFS()
+	for _, b := range buildfs {
+		op := strings.Split(b, " ")
+		switch op[0] {
+		case "mkdir":
+			err := fs.Mkdir(op[1], os.ModeDir|0777)
+			if err != nil {
+				return nil, err
+			}
+		case "touch":
+			f, err := fs.OpenFile(op[1], os.O_RDWR|os.O_CREATE, 0666)
+			if err != nil {
+				return nil, err
+			}
+			f.Close()
+		case "write":
+			f, err := fs.OpenFile(op[1], os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666)
+			if err != nil {
+				return nil, err
+			}
+			_, err = f.Write([]byte(op[2]))
+			f.Close()
+			if err != nil {
+				return nil, err
+			}
+		default:
+			return nil, fmt.Errorf("unknown file operation %q", op[0])
+		}
+	}
+	return fs, nil
+}
diff --git a/webdav/litmus_test_server.go b/webdav/litmus_test_server.go
index e75e01e..8aea999 100644
--- a/webdav/litmus_test_server.go
+++ b/webdav/litmus_test_server.go
@@ -32,9 +32,12 @@
 func main() {
 	flag.Parse()
 	log.SetFlags(0)
+	fs := webdav.NewMemFS()
+	ls := webdav.NewMemLS()
 	http.Handle("/", &webdav.Handler{
-		FileSystem: webdav.NewMemFS(),
-		LockSystem: webdav.NewMemLS(),
+		FileSystem: fs,
+		LockSystem: ls,
+		PropSystem: webdav.NewMemPS(fs, ls),
 		Logger: func(r *http.Request, err error) {
 			litmus := r.Header.Get("X-Litmus")
 			if len(litmus) > 19 {
diff --git a/webdav/prop.go b/webdav/prop.go
new file mode 100644
index 0000000..4ab4919
--- /dev/null
+++ b/webdav/prop.go
@@ -0,0 +1,195 @@
+// Copyright 2015 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"
+	"os"
+	"strconv"
+)
+
+// PropSystem manages the properties of named resources. It allows finding
+// and setting properties as defined in RFC 4918.
+//
+// The elements in a resource name are separated by slash ('/', U+002F)
+// characters, regardless of host operating system convention.
+type PropSystem interface {
+	// Find returns the status of properties named propnames for resource name.
+	//
+	// Each Propstat must have a unique status and each property name must
+	// only be part of one Propstat element.
+	Find(name string, propnames []xml.Name) ([]Propstat, error)
+
+	// TODO(rost) PROPPATCH.
+	// TODO(nigeltao) merge Find and Allprop?
+
+	// Allprop returns the properties defined for resource name and the
+	// properties named in include. The returned Propstats are handled
+	// as in Find.
+	//
+	// Note that RFC 4918 defines 'allprop' to return the DAV: properties
+	// defined within the RFC plus dead properties. Other live properties
+	// should only be returned if they are named in 'include'.
+	//
+	// See http://www.webdav.org/specs/rfc4918.html#METHOD_PROPFIND
+	Allprop(name string, include []xml.Name) ([]Propstat, error)
+
+	// Propnames returns the property names defined for resource name.
+	Propnames(name string) ([]xml.Name, error)
+
+	// TODO(rost) COPY/MOVE/DELETE.
+}
+
+// Propstat describes a XML propstat element as defined in RFC 4918.
+// See http://www.webdav.org/specs/rfc4918.html#ELEMENT_propstat
+type Propstat struct {
+	// Props contains the properties for which Status applies.
+	Props []Property
+
+	// Status defines the HTTP status code of the properties in Prop.
+	// Allowed values include, but are not limited to the WebDAV status
+	// code extensions for HTTP/1.1.
+	// http://www.webdav.org/specs/rfc4918.html#status.code.extensions.to.http11
+	Status int
+
+	// XMLError contains the XML representation of the optional error element.
+	// XML content within this field must not rely on any predefined
+	// namespace declarations or prefixes. If empty, the XML error element
+	// is omitted.
+	XMLError string
+
+	// ResponseDescription contains the contents of the optional
+	// responsedescription field. If empty, the XML element is omitted.
+	ResponseDescription string
+}
+
+// memPS implements an in-memory PropSystem. It supports all of the mandatory
+// live properties of RFC 4918.
+type memPS struct {
+	// TODO(rost) memPS will get writeable in the next CLs.
+	fs FileSystem
+	ls LockSystem
+}
+
+// NewMemPS returns a new in-memory PropSystem implementation.
+func NewMemPS(fs FileSystem, ls LockSystem) PropSystem {
+	return &memPS{fs: fs, ls: ls}
+}
+
+type propfindFn func(*memPS, string, os.FileInfo) (string, error)
+
+// davProps contains all supported DAV: properties and their optional
+// propfind functions. A nil value indicates a hidden, protected property.
+var davProps = map[xml.Name]propfindFn{
+	xml.Name{Space: "DAV:", Local: "resourcetype"}:       (*memPS).findResourceType,
+	xml.Name{Space: "DAV:", Local: "displayname"}:        (*memPS).findDisplayName,
+	xml.Name{Space: "DAV:", Local: "getcontentlength"}:   (*memPS).findContentLength,
+	xml.Name{Space: "DAV:", Local: "getlastmodified"}:    (*memPS).findLastModified,
+	xml.Name{Space: "DAV:", Local: "creationdate"}:       nil,
+	xml.Name{Space: "DAV:", Local: "getcontentlanguage"}: nil,
+
+	// TODO(rost) ETag and ContentType will be defined the next CL.
+	// xml.Name{Space: "DAV:", Local: "getcontenttype"}:     (*memPS).findContentType,
+	// xml.Name{Space: "DAV:", Local: "getetag"}:            (*memPS).findEtag,
+
+	// TODO(nigeltao) Lock properties will be defined later.
+	// xml.Name{Space: "DAV:", Local: "lockdiscovery"}: nil, // TODO(rost)
+	// xml.Name{Space: "DAV:", Local: "supportedlock"}: nil, // TODO(rost)
+}
+
+func (ps *memPS) Find(name string, propnames []xml.Name) ([]Propstat, error) {
+	fi, err := ps.fs.Stat(name)
+	if err != nil {
+		return nil, err
+	}
+
+	pm := make(map[int]Propstat)
+	for _, pn := range propnames {
+		p := Property{XMLName: pn}
+		s := http.StatusNotFound
+		if fn := davProps[pn]; fn != nil {
+			xmlvalue, err := fn(ps, name, fi)
+			if err != nil {
+				return nil, err
+			}
+			s = http.StatusOK
+			p.InnerXML = []byte(xmlvalue)
+		}
+		pstat := pm[s]
+		pstat.Props = append(pstat.Props, p)
+		pm[s] = pstat
+	}
+
+	pstats := make([]Propstat, 0, len(pm))
+	for s, pstat := range pm {
+		pstat.Status = s
+		pstats = append(pstats, pstat)
+	}
+	return pstats, nil
+}
+
+func (ps *memPS) Propnames(name string) ([]xml.Name, error) {
+	fi, err := ps.fs.Stat(name)
+	if err != nil {
+		return nil, err
+	}
+	propnames := make([]xml.Name, 0, len(davProps))
+	for pn, findFn := range davProps {
+		// TODO(rost) ETag and ContentType will be defined the next CL.
+		// memPS implements ETag as the concatenated hex values of a file's
+		// modification time and size. This is not a reliable synchronization
+		// mechanism for directories, so we do not advertise getetag for
+		// DAV collections. Other property systems may do how they please.
+		if fi.IsDir() && pn.Space == "DAV:" && pn.Local == "getetag" {
+			continue
+		}
+		if findFn != nil {
+			propnames = append(propnames, pn)
+		}
+	}
+	return propnames, nil
+}
+
+func (ps *memPS) Allprop(name string, include []xml.Name) ([]Propstat, error) {
+	propnames, err := ps.Propnames(name)
+	if err != nil {
+		return nil, err
+	}
+	// Add names from include if they are not already covered in propnames.
+	nameset := make(map[xml.Name]bool)
+	for _, pn := range propnames {
+		nameset[pn] = true
+	}
+	for _, pn := range include {
+		if !nameset[pn] {
+			propnames = append(propnames, pn)
+		}
+	}
+	return ps.Find(name, propnames)
+}
+
+func (ps *memPS) findResourceType(name string, fi os.FileInfo) (string, error) {
+	if fi.IsDir() {
+		return `<collection xmlns="DAV:"/>`, nil
+	}
+	return "", nil
+}
+
+func (ps *memPS) findDisplayName(name string, fi os.FileInfo) (string, error) {
+	if slashClean(name) == "/" {
+		// Hide the real name of a possibly prefixed root directory.
+		return "", nil
+	}
+	return fi.Name(), nil
+}
+
+func (ps *memPS) findContentLength(name string, fi os.FileInfo) (string, error) {
+	return strconv.FormatInt(fi.Size(), 10), nil
+}
+
+func (ps *memPS) findLastModified(name string, fi os.FileInfo) (string, error) {
+	return fi.ModTime().Format(http.TimeFormat), nil
+}
diff --git a/webdav/prop_test.go b/webdav/prop_test.go
new file mode 100644
index 0000000..d5f8e78
--- /dev/null
+++ b/webdav/prop_test.go
@@ -0,0 +1,304 @@
+// Copyright 2015 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"
+	"fmt"
+	"net/http"
+	"reflect"
+	"sort"
+	"testing"
+)
+
+func TestMemPS(t *testing.T) {
+	// calcProps calculates the getlastmodified and getetag DAV: property
+	// values in pstats for resource name in file-system fs.
+	calcProps := func(name string, fs FileSystem, pstats []Propstat) error {
+		fi, err := fs.Stat(name)
+		if err != nil {
+			return err
+		}
+		for _, pst := range pstats {
+			for i, p := range pst.Props {
+				switch p.XMLName {
+				case xml.Name{Space: "DAV:", Local: "getlastmodified"}:
+					p.InnerXML = []byte(fi.ModTime().Format(http.TimeFormat))
+					pst.Props[i] = p
+				case xml.Name{Space: "DAV:", Local: "getetag"}:
+					// TODO(rost) ETag will be defined in the next CL.
+					panic("Not implemented")
+				}
+			}
+		}
+		return nil
+	}
+
+	type propOp struct {
+		op            string
+		name          string
+		propnames     []xml.Name
+		wantNames     []xml.Name
+		wantPropstats []Propstat
+	}
+
+	testCases := []struct {
+		desc    string
+		buildfs []string
+		propOp  []propOp
+	}{{
+		"propname",
+		[]string{"mkdir /dir", "touch /file"},
+		[]propOp{{
+			op:   "propname",
+			name: "/dir",
+			wantNames: []xml.Name{
+				xml.Name{Space: "DAV:", Local: "resourcetype"},
+				xml.Name{Space: "DAV:", Local: "displayname"},
+				xml.Name{Space: "DAV:", Local: "getcontentlength"},
+				xml.Name{Space: "DAV:", Local: "getlastmodified"},
+			},
+		}, {
+			op:   "propname",
+			name: "/file",
+			wantNames: []xml.Name{
+				xml.Name{Space: "DAV:", Local: "resourcetype"},
+				xml.Name{Space: "DAV:", Local: "displayname"},
+				xml.Name{Space: "DAV:", Local: "getcontentlength"},
+				xml.Name{Space: "DAV:", Local: "getlastmodified"},
+			},
+		}},
+	}, {
+		"allprop dir and file",
+		[]string{"mkdir /dir", "write /file foobarbaz"},
+		[]propOp{{
+			op:   "allprop",
+			name: "/dir",
+			wantPropstats: []Propstat{{
+				Status: http.StatusOK,
+				Props: []Property{{
+					XMLName:  xml.Name{Space: "DAV:", Local: "resourcetype"},
+					InnerXML: []byte(`<collection xmlns="DAV:"/>`),
+				}, {
+					XMLName:  xml.Name{Space: "DAV:", Local: "displayname"},
+					InnerXML: []byte("dir"),
+				}, {
+					XMLName:  xml.Name{Space: "DAV:", Local: "getcontentlength"},
+					InnerXML: []byte("0"),
+				}, {
+					XMLName:  xml.Name{Space: "DAV:", Local: "getlastmodified"},
+					InnerXML: nil, // Calculated during test.
+				}},
+			}},
+		}, {
+			op:   "allprop",
+			name: "/file",
+			wantPropstats: []Propstat{{
+				Status: http.StatusOK,
+				Props: []Property{{
+					XMLName:  xml.Name{Space: "DAV:", Local: "resourcetype"},
+					InnerXML: []byte(""),
+				}, {
+					XMLName:  xml.Name{Space: "DAV:", Local: "displayname"},
+					InnerXML: []byte("file"),
+				}, {
+					XMLName:  xml.Name{Space: "DAV:", Local: "getcontentlength"},
+					InnerXML: []byte("9"),
+				}, {
+					XMLName:  xml.Name{Space: "DAV:", Local: "getlastmodified"},
+					InnerXML: nil, // Calculated during test.
+				}},
+			}},
+		}, {
+			op:   "allprop",
+			name: "/file",
+			propnames: []xml.Name{
+				{"DAV:", "resourcetype"},
+				{"foo", "bar"},
+			},
+			wantPropstats: []Propstat{{
+				Status: http.StatusOK,
+				Props: []Property{{
+					XMLName:  xml.Name{Space: "DAV:", Local: "resourcetype"},
+					InnerXML: []byte(""),
+				}, {
+					XMLName:  xml.Name{Space: "DAV:", Local: "displayname"},
+					InnerXML: []byte("file"),
+				}, {
+					XMLName:  xml.Name{Space: "DAV:", Local: "getcontentlength"},
+					InnerXML: []byte("9"),
+				}, {
+					XMLName:  xml.Name{Space: "DAV:", Local: "getlastmodified"},
+					InnerXML: nil, // Calculated during test.
+				}}}, {
+				Status: http.StatusNotFound,
+				Props: []Property{{
+					XMLName: xml.Name{Space: "foo", Local: "bar"},
+				}}},
+			},
+		}},
+	}, {
+		"propfind DAV:resourcetype",
+		[]string{"mkdir /dir", "touch /file"},
+		[]propOp{{
+			op:        "propfind",
+			name:      "/dir",
+			propnames: []xml.Name{{"DAV:", "resourcetype"}},
+			wantPropstats: []Propstat{{
+				Status: http.StatusOK,
+				Props: []Property{{
+					XMLName:  xml.Name{Space: "DAV:", Local: "resourcetype"},
+					InnerXML: []byte(`<collection xmlns="DAV:"/>`),
+				}},
+			}},
+		}, {
+			op:        "propfind",
+			name:      "/file",
+			propnames: []xml.Name{{"DAV:", "resourcetype"}},
+			wantPropstats: []Propstat{{
+				Status: http.StatusOK,
+				Props: []Property{{
+					XMLName:  xml.Name{Space: "DAV:", Local: "resourcetype"},
+					InnerXML: []byte(""),
+				}},
+			}},
+		}},
+	}, {
+		"propfind unsupported DAV properties",
+		[]string{"mkdir /dir"},
+		[]propOp{{
+			op:        "propfind",
+			name:      "/dir",
+			propnames: []xml.Name{{"DAV:", "getcontentlanguage"}},
+			wantPropstats: []Propstat{{
+				Status: http.StatusNotFound,
+				Props: []Property{{
+					XMLName: xml.Name{Space: "DAV:", Local: "getcontentlanguage"},
+				}},
+			}},
+		}, {
+			op:        "propfind",
+			name:      "/dir",
+			propnames: []xml.Name{{"DAV:", "creationdate"}},
+			wantPropstats: []Propstat{{
+				Status: http.StatusNotFound,
+				Props: []Property{{
+					XMLName: xml.Name{Space: "DAV:", Local: "creationdate"},
+				}},
+			}},
+		}},
+	}, {
+		"bad: propfind unknown property",
+		[]string{"mkdir /dir"},
+		[]propOp{{
+			op:        "propfind",
+			name:      "/dir",
+			propnames: []xml.Name{{"foo:", "bar"}},
+			wantPropstats: []Propstat{{
+				Status: http.StatusNotFound,
+				Props: []Property{{
+					XMLName: xml.Name{Space: "foo:", Local: "bar"},
+				}},
+			}},
+		}},
+	}}
+
+	for _, tc := range testCases {
+		fs, err := buildTestFS(tc.buildfs)
+		if err != nil {
+			t.Fatalf("%s: cannot create test filesystem: %v", tc.desc, err)
+		}
+		ls := NewMemLS()
+		ps := NewMemPS(fs, ls)
+		for _, op := range tc.propOp {
+			desc := fmt.Sprintf("%s: %s %s", tc.desc, op.op, op.name)
+			if err = calcProps(op.name, fs, op.wantPropstats); err != nil {
+				t.Fatalf("%s: calcProps: %v", desc, err)
+			}
+
+			// Call property system.
+			var propstats []Propstat
+			switch op.op {
+			case "propname":
+				names, err := ps.Propnames(op.name)
+				if err != nil {
+					t.Errorf("%s: got error %v, want nil", desc, err)
+					continue
+				}
+				sort.Sort(byXMLName(names))
+				sort.Sort(byXMLName(op.wantNames))
+				if !reflect.DeepEqual(names, op.wantNames) {
+					t.Errorf("%s: names\ngot  %q\nwant %q", desc, names, op.wantNames)
+				}
+				continue
+			case "allprop":
+				propstats, err = ps.Allprop(op.name, op.propnames)
+			case "propfind":
+				propstats, err = ps.Find(op.name, op.propnames)
+			default:
+				t.Fatalf("%s: %s not implemented", desc, op.op)
+			}
+			if err != nil {
+				t.Errorf("%s: got error %v, want nil", desc, err)
+				continue
+			}
+			// Compare return values from allprop or propfind.
+			for _, pst := range propstats {
+				sort.Sort(byPropname(pst.Props))
+			}
+			for _, pst := range op.wantPropstats {
+				sort.Sort(byPropname(pst.Props))
+			}
+			sort.Sort(byStatus(propstats))
+			sort.Sort(byStatus(op.wantPropstats))
+			if !reflect.DeepEqual(propstats, op.wantPropstats) {
+				t.Errorf("%s: propstat\ngot  %q\nwant %q", desc, propstats, op.wantPropstats)
+			}
+		}
+	}
+}
+
+func cmpXMLName(a, b xml.Name) bool {
+	if a.Space != b.Space {
+		return a.Space < b.Space
+	}
+	return a.Local < b.Local
+}
+
+type byXMLName []xml.Name
+
+func (b byXMLName) Len() int {
+	return len(b)
+}
+func (b byXMLName) Swap(i, j int) {
+	b[i], b[j] = b[j], b[i]
+}
+func (b byXMLName) Less(i, j int) bool {
+	return cmpXMLName(b[i], b[j])
+}
+
+type byPropname []Property
+
+func (b byPropname) Len() int {
+	return len(b)
+}
+func (b byPropname) Swap(i, j int) {
+	b[i], b[j] = b[j], b[i]
+}
+func (b byPropname) Less(i, j int) bool {
+	return cmpXMLName(b[i].XMLName, b[j].XMLName)
+}
+
+type byStatus []Propstat
+
+func (b byStatus) Len() int {
+	return len(b)
+}
+func (b byStatus) Swap(i, j int) {
+	b[i], b[j] = b[j], b[i]
+}
+func (b byStatus) Less(i, j int) bool {
+	return b[i].Status < b[j].Status
+}
diff --git a/webdav/webdav.go b/webdav/webdav.go
index 45484b6..f4acc65 100644
--- a/webdav/webdav.go
+++ b/webdav/webdav.go
@@ -9,6 +9,7 @@
 
 import (
 	"errors"
+	"fmt"
 	"io"
 	"net/http"
 	"net/url"
@@ -16,15 +17,12 @@
 	"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 is the property management system.
 	PropSystem PropSystem
 	// Logger is an optional error logger. If non-nil, it will be called
 	// for all HTTP requests.
@@ -37,8 +35,9 @@
 		status, err = http.StatusInternalServerError, errNoFileSystem
 	} else if h.LockSystem == nil {
 		status, err = http.StatusInternalServerError, errNoLockSystem
+	} else if h.PropSystem == nil {
+		status, err = http.StatusInternalServerError, errNoPropSystem
 	} else {
-		// TODO: PROPFIND, PROPPATCH methods.
 		switch r.Method {
 		case "OPTIONS":
 			status, err = h.handleOptions(w, r)
@@ -56,6 +55,8 @@
 			status, err = h.handleLock(w, r)
 		case "UNLOCK":
 			status, err = h.handleUnlock(w, r)
+		case "PROPFIND":
+			status, err = h.handlePropfind(w, r)
 		}
 	}
 
@@ -429,6 +430,88 @@
 	}
 }
 
+func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) (status int, err error) {
+	fi, err := h.FileSystem.Stat(r.URL.Path)
+	if err != nil {
+		if err == os.ErrNotExist {
+			return http.StatusNotFound, err
+		}
+		return http.StatusMethodNotAllowed, err
+	}
+	depth := infiniteDepth
+	if hdr := r.Header.Get("Depth"); hdr != "" {
+		depth = parseDepth(hdr)
+		if depth == invalidDepth {
+			return http.StatusBadRequest, errInvalidDepth
+		}
+	}
+	pf, status, err := readPropfind(r.Body)
+	if err != nil {
+		return status, err
+	}
+
+	mw := multistatusWriter{w: w}
+
+	walkFn := func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+		var pstats []Propstat
+		if pf.Propname != nil {
+			propnames, err := h.PropSystem.Propnames(path)
+			if err != nil {
+				return err
+			}
+			pstat := Propstat{Status: http.StatusOK}
+			for _, xmlname := range propnames {
+				pstat.Props = append(pstat.Props, Property{XMLName: xmlname})
+			}
+			pstats = append(pstats, pstat)
+		} else if pf.Allprop != nil {
+			pstats, err = h.PropSystem.Allprop(path, pf.Prop)
+		} else {
+			pstats, err = h.PropSystem.Find(path, pf.Prop)
+		}
+		if err != nil {
+			return err
+		}
+		return mw.write(makePropstatResponse(path, pstats))
+	}
+
+	err = walkFS(h.FileSystem, depth, r.URL.Path, fi, walkFn)
+	if mw.enc == nil {
+		if err == nil {
+			err = errEmptyMultistatus
+		}
+		// Not a single response has been written.
+		return http.StatusInternalServerError, err
+	}
+	if err != nil {
+		return 0, err
+	}
+	return 0, mw.close()
+}
+
+func makePropstatResponse(href string, pstats []Propstat) *response {
+	resp := response{
+		Href:     []string{href},
+		Propstat: make([]propstat, 0, len(pstats)),
+	}
+	for _, p := range pstats {
+		var xmlErr *xmlError
+		if p.XMLError != "" {
+			xmlErr = &xmlError{InnerXML: []byte(p.XMLError)}
+		}
+		resp.Propstat = append(resp.Propstat, propstat{
+			Status:              fmt.Sprintf("HTTP/1.1 %d %s", p.Status, StatusText(p.Status)),
+			Prop:                p.Props,
+			ResponseDescription: p.ResponseDescription,
+			Error:               xmlErr,
+		})
+	}
+	return &resp
+}
+
 const (
 	infiniteDepth = -1
 	invalidDepth  = -2
@@ -483,6 +566,7 @@
 var (
 	errDestinationEqualsSource = errors.New("webdav: destination equals source")
 	errDirectoryNotEmpty       = errors.New("webdav: directory not empty")
+	errEmptyMultistatus        = errors.New("webdav: empty multistatus response")
 	errInvalidDepth            = errors.New("webdav: invalid depth")
 	errInvalidDestination      = errors.New("webdav: invalid destination")
 	errInvalidIfHeader         = errors.New("webdav: invalid If header")
@@ -493,6 +577,7 @@
 	errInvalidTimeout          = errors.New("webdav: invalid timeout")
 	errNoFileSystem            = errors.New("webdav: no file system")
 	errNoLockSystem            = errors.New("webdav: no lock system")
+	errNoPropSystem            = errors.New("webdav: no property system")
 	errNotADirectory           = errors.New("webdav: not a directory")
 	errRecursionTooDeep        = errors.New("webdav: recursion too deep")
 	errUnsupportedLockInfo     = errors.New("webdav: unsupported lock info")
diff --git a/webdav/xml.go b/webdav/xml.go
index b6534be..0bfceea 100644
--- a/webdav/xml.go
+++ b/webdav/xml.go
@@ -114,6 +114,7 @@
 	}
 }
 
+// http://www.webdav.org/specs/rfc4918.html#ELEMENT_prop (for propfind)
 type propnames []xml.Name
 
 // UnmarshalXML appends the property names enclosed within start to pn.