webdav: allow the user to override the ETag and ContentType properties

Before this commit it was not possible to override the the ContentType
and ETag properties.

Since these properties aren't directly read from the os.FileInfo
objects returned by the FileSystem it seems reasonable that the user
might have a different policy for computing them.

For instance the underlying FileSystem may already know the
ContentType or want to use an MD5 Hash for the ETag.

This commit introduces two new optional interfaces ETager and
ContentTyper which, when defined on the os.FileInfo objects
returned by the FileSystem methods, allows the user of this library to
override the ETag and ContentType generation.

Fixes golang/go#22577

Change-Id: Ib42e126db3fcc0a93463e61db85fde59be85cca5
Reviewed-on: https://go-review.googlesource.com/109217
Reviewed-by: Brad Fitzpatrick <bradfitz@golang.org>
Run-TryBot: Brad Fitzpatrick <bradfitz@golang.org>
TryBot-Result: Gobot Gobot <gobot@golang.org>
diff --git a/webdav/prop.go b/webdav/prop.go
index ba595d7..4d7be34 100644
--- a/webdav/prop.go
+++ b/webdav/prop.go
@@ -7,6 +7,7 @@
 import (
 	"bytes"
 	"encoding/xml"
+	"errors"
 	"fmt"
 	"io"
 	"mime"
@@ -379,7 +380,34 @@
 	return fi.ModTime().UTC().Format(http.TimeFormat), nil
 }
 
+// ErrNotImplemented should be returned by optional interfaces if they
+// want the original implementation to be used.
+var ErrNotImplemented = errors.New("not implemented")
+
+// ContentTyper is an optional interface for the os.FileInfo
+// objects returned by the FileSystem.
+//
+// If this interface is defined then it will be used to read the
+// content type from the object.
+//
+// If this interface is not defined the file will be opened and the
+// content type will be guessed from the initial contents of the file.
+type ContentTyper interface {
+	// ContentType returns the content type for the file.
+	//
+	// If this returns error ErrNotImplemented then the error will
+	// be ignored and the base implementation will be used
+	// instead.
+	ContentType(ctx context.Context) (string, error)
+}
+
 func findContentType(ctx context.Context, fs FileSystem, ls LockSystem, name string, fi os.FileInfo) (string, error) {
+	if do, ok := fi.(ContentTyper); ok {
+		ctype, err := do.ContentType(ctx)
+		if err != ErrNotImplemented {
+			return ctype, err
+		}
+	}
 	f, err := fs.OpenFile(ctx, name, os.O_RDONLY, 0)
 	if err != nil {
 		return "", err
@@ -402,7 +430,31 @@
 	return ctype, err
 }
 
+// ETager is an optional interface for the os.FileInfo objects
+// returned by the FileSystem.
+//
+// If this interface is defined then it will be used to read the ETag
+// for the object.
+//
+// If this interface is not defined an ETag will be computed using the
+// ModTime() and the Size() methods of the os.FileInfo object.
+type ETager interface {
+	// ETag returns an ETag for the file.  This should be of the
+	// form "value" or W/"value"
+	//
+	// If this returns error ErrNotImplemented then the error will
+	// be ignored and the base implementation will be used
+	// instead.
+	ETag(ctx context.Context) (string, error)
+}
+
 func findETag(ctx context.Context, fs FileSystem, ls LockSystem, name string, fi os.FileInfo) (string, error) {
+	if do, ok := fi.(ETager); ok {
+		etag, err := do.ETag(ctx)
+		if err != ErrNotImplemented {
+			return etag, err
+		}
+	}
 	// The Apache http 2.4 web server by default concatenates the
 	// modification time and size of a file. We replicate the heuristic
 	// with nanosecond granularity.
diff --git a/webdav/prop_test.go b/webdav/prop_test.go
index 914f950..bc42b91 100644
--- a/webdav/prop_test.go
+++ b/webdav/prop_test.go
@@ -10,6 +10,7 @@
 	"net/http"
 	"os"
 	"reflect"
+	"regexp"
 	"sort"
 	"testing"
 
@@ -611,3 +612,106 @@
 func (f noDeadPropsFile) Seek(off int64, whence int) (int64, error) { return f.f.Seek(off, whence) }
 func (f noDeadPropsFile) Stat() (os.FileInfo, error)                { return f.f.Stat() }
 func (f noDeadPropsFile) Write(p []byte) (int, error)               { return f.f.Write(p) }
+
+type overrideContentType struct {
+	os.FileInfo
+	contentType string
+	err         error
+}
+
+func (o *overrideContentType) ContentType(ctx context.Context) (string, error) {
+	return o.contentType, o.err
+}
+
+func TestFindContentTypeOverride(t *testing.T) {
+	fs, err := buildTestFS([]string{"touch /file"})
+	if err != nil {
+		t.Fatalf("cannot create test filesystem: %v", err)
+	}
+	ctx := context.Background()
+	fi, err := fs.Stat(ctx, "/file")
+	if err != nil {
+		t.Fatalf("cannot Stat /file: %v", err)
+	}
+
+	// Check non overridden case
+	originalContentType, err := findContentType(ctx, fs, nil, "/file", fi)
+	if err != nil {
+		t.Fatalf("findContentType /file failed: %v", err)
+	}
+	if originalContentType != "text/plain; charset=utf-8" {
+		t.Fatalf("ContentType wrong want %q got %q", "text/plain; charset=utf-8", originalContentType)
+	}
+
+	// Now try overriding the ContentType
+	o := &overrideContentType{fi, "OverriddenContentType", nil}
+	ContentType, err := findContentType(ctx, fs, nil, "/file", o)
+	if err != nil {
+		t.Fatalf("findContentType /file failed: %v", err)
+	}
+	if ContentType != o.contentType {
+		t.Fatalf("ContentType wrong want %q got %q", o.contentType, ContentType)
+	}
+
+	// Now return ErrNotImplemented and check we get the original content type
+	o = &overrideContentType{fi, "OverriddenContentType", ErrNotImplemented}
+	ContentType, err = findContentType(ctx, fs, nil, "/file", o)
+	if err != nil {
+		t.Fatalf("findContentType /file failed: %v", err)
+	}
+	if ContentType != originalContentType {
+		t.Fatalf("ContentType wrong want %q got %q", originalContentType, ContentType)
+	}
+}
+
+type overrideETag struct {
+	os.FileInfo
+	eTag string
+	err  error
+}
+
+func (o *overrideETag) ETag(ctx context.Context) (string, error) {
+	return o.eTag, o.err
+}
+
+func TestFindETagOverride(t *testing.T) {
+	fs, err := buildTestFS([]string{"touch /file"})
+	if err != nil {
+		t.Fatalf("cannot create test filesystem: %v", err)
+	}
+	ctx := context.Background()
+	fi, err := fs.Stat(ctx, "/file")
+	if err != nil {
+		t.Fatalf("cannot Stat /file: %v", err)
+	}
+
+	// Check non overridden case
+	originalETag, err := findETag(ctx, fs, nil, "/file", fi)
+	if err != nil {
+		t.Fatalf("findETag /file failed: %v", err)
+	}
+	matchETag := regexp.MustCompile(`^"-?[0-9a-f]{6,}"$`)
+	if !matchETag.MatchString(originalETag) {
+		t.Fatalf("ETag wrong, wanted something matching %v got %q", matchETag, originalETag)
+	}
+
+	// Now try overriding the ETag
+	o := &overrideETag{fi, `"OverriddenETag"`, nil}
+	ETag, err := findETag(ctx, fs, nil, "/file", o)
+	if err != nil {
+		t.Fatalf("findETag /file failed: %v", err)
+	}
+	if ETag != o.eTag {
+		t.Fatalf("ETag wrong want %q got %q", o.eTag, ETag)
+	}
+
+	// Now return ErrNotImplemented and check we get the original Etag
+	o = &overrideETag{fi, `"OverriddenETag"`, ErrNotImplemented}
+	ETag, err = findETag(ctx, fs, nil, "/file", o)
+	if err != nil {
+		t.Fatalf("findETag /file failed: %v", err)
+	}
+	if ETag != originalETag {
+		t.Fatalf("ETag wrong want %q got %q", originalETag, ETag)
+	}
+}