github.com/divyam234/rclone@v1.64.1/cmd/serve/webdav/webdav.go (about)

     1  // Package webdav implements a WebDAV server backed by rclone VFS
     2  package webdav
     3  
     4  import (
     5  	"context"
     6  	"encoding/xml"
     7  	"errors"
     8  	"fmt"
     9  	"mime"
    10  	"net/http"
    11  	"os"
    12  	"path"
    13  	"strconv"
    14  	"strings"
    15  	"time"
    16  
    17  	chi "github.com/go-chi/chi/v5"
    18  	"github.com/go-chi/chi/v5/middleware"
    19  	"github.com/divyam234/rclone/cmd"
    20  	"github.com/divyam234/rclone/cmd/serve/proxy"
    21  	"github.com/divyam234/rclone/cmd/serve/proxy/proxyflags"
    22  	"github.com/divyam234/rclone/fs"
    23  	"github.com/divyam234/rclone/fs/config/flags"
    24  	"github.com/divyam234/rclone/fs/hash"
    25  	libhttp "github.com/divyam234/rclone/lib/http"
    26  	"github.com/divyam234/rclone/lib/http/serve"
    27  	"github.com/divyam234/rclone/vfs"
    28  	"github.com/divyam234/rclone/vfs/vfsflags"
    29  	"github.com/spf13/cobra"
    30  	"golang.org/x/net/webdav"
    31  )
    32  
    33  // Options required for http server
    34  type Options struct {
    35  	Auth          libhttp.AuthConfig
    36  	HTTP          libhttp.Config
    37  	Template      libhttp.TemplateConfig
    38  	HashName      string
    39  	HashType      hash.Type
    40  	DisableGETDir bool
    41  }
    42  
    43  // DefaultOpt is the default values used for Options
    44  var DefaultOpt = Options{
    45  	Auth:          libhttp.DefaultAuthCfg(),
    46  	HTTP:          libhttp.DefaultCfg(),
    47  	Template:      libhttp.DefaultTemplateCfg(),
    48  	HashType:      hash.None,
    49  	DisableGETDir: false,
    50  }
    51  
    52  // Opt is options set by command line flags
    53  var Opt = DefaultOpt
    54  
    55  // flagPrefix is the prefix used to uniquely identify command line flags.
    56  // It is intentionally empty for this package.
    57  const flagPrefix = ""
    58  
    59  func init() {
    60  	flagSet := Command.Flags()
    61  	libhttp.AddAuthFlagsPrefix(flagSet, flagPrefix, &Opt.Auth)
    62  	libhttp.AddHTTPFlagsPrefix(flagSet, flagPrefix, &Opt.HTTP)
    63  	libhttp.AddTemplateFlagsPrefix(flagSet, "", &Opt.Template)
    64  	vfsflags.AddFlags(flagSet)
    65  	proxyflags.AddFlags(flagSet)
    66  	flags.StringVarP(flagSet, &Opt.HashName, "etag-hash", "", "", "Which hash to use for the ETag, or auto or blank for off", "")
    67  	flags.BoolVarP(flagSet, &Opt.DisableGETDir, "disable-dir-list", "", false, "Disable HTML directory list on GET request for a directory", "")
    68  }
    69  
    70  // Command definition for cobra
    71  var Command = &cobra.Command{
    72  	Use:   "webdav remote:path",
    73  	Short: `Serve remote:path over WebDAV.`,
    74  	Long: `Run a basic WebDAV server to serve a remote over HTTP via the
    75  WebDAV protocol. This can be viewed with a WebDAV client, through a web
    76  browser, or you can make a remote of type WebDAV to read and write it.
    77  
    78  ### WebDAV options
    79  
    80  #### --etag-hash 
    81  
    82  This controls the ETag header.  Without this flag the ETag will be
    83  based on the ModTime and Size of the object.
    84  
    85  If this flag is set to "auto" then rclone will choose the first
    86  supported hash on the backend or you can use a named hash such as
    87  "MD5" or "SHA-1". Use the [hashsum](/commands/rclone_hashsum/) command
    88  to see the full list.
    89  
    90  ### Access WebDAV on Windows
    91  WebDAV shared folder can be mapped as a drive on Windows, however the default settings prevent it.
    92  Windows will fail to connect to the server using insecure Basic authentication.
    93  It will not even display any login dialog. Windows requires SSL / HTTPS connection to be used with Basic.
    94  If you try to connect via Add Network Location Wizard you will get the following error:
    95  "The folder you entered does not appear to be valid. Please choose another".
    96  However, you still can connect if you set the following registry key on a client machine:
    97  HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\WebClient\Parameters\BasicAuthLevel to 2.
    98  The BasicAuthLevel can be set to the following values:
    99      0 - Basic authentication disabled
   100      1 - Basic authentication enabled for SSL connections only
   101      2 - Basic authentication enabled for SSL connections and for non-SSL connections
   102  If required, increase the FileSizeLimitInBytes to a higher value.
   103  Navigate to the Services interface, then restart the WebClient service.
   104  
   105  ### Access Office applications on WebDAV
   106  Navigate to following registry HKEY_CURRENT_USER\Software\Microsoft\Office\[14.0/15.0/16.0]\Common\Internet
   107  Create a new DWORD BasicAuthLevel with value 2.
   108      0 - Basic authentication disabled
   109      1 - Basic authentication enabled for SSL connections only
   110      2 - Basic authentication enabled for SSL and for non-SSL connections
   111  
   112  https://learn.microsoft.com/en-us/office/troubleshoot/powerpoint/office-opens-blank-from-sharepoint
   113  
   114  ` + libhttp.Help(flagPrefix) + libhttp.TemplateHelp(flagPrefix) + libhttp.AuthHelp(flagPrefix) + vfs.Help + proxy.Help,
   115  	Annotations: map[string]string{
   116  		"versionIntroduced": "v1.39",
   117  		"groups":            "Filter",
   118  	},
   119  	RunE: func(command *cobra.Command, args []string) error {
   120  		var f fs.Fs
   121  		if proxyflags.Opt.AuthProxy == "" {
   122  			cmd.CheckArgs(1, 1, command, args)
   123  			f = cmd.NewFsSrc(args)
   124  		} else {
   125  			cmd.CheckArgs(0, 0, command, args)
   126  		}
   127  		Opt.HashType = hash.None
   128  		if Opt.HashName == "auto" {
   129  			Opt.HashType = f.Hashes().GetOne()
   130  		} else if Opt.HashName != "" {
   131  			err := Opt.HashType.Set(Opt.HashName)
   132  			if err != nil {
   133  				return err
   134  			}
   135  		}
   136  		if Opt.HashType != hash.None {
   137  			fs.Debugf(f, "Using hash %v for ETag", Opt.HashType)
   138  		}
   139  		cmd.Run(false, false, command, func() error {
   140  			s, err := newWebDAV(context.Background(), f, &Opt)
   141  			if err != nil {
   142  				return err
   143  			}
   144  			err = s.serve()
   145  			if err != nil {
   146  				return err
   147  			}
   148  			s.Wait()
   149  			return nil
   150  		})
   151  		return nil
   152  	},
   153  }
   154  
   155  // WebDAV is a webdav.FileSystem interface
   156  //
   157  // A FileSystem implements access to a collection of named files. The elements
   158  // in a file path are separated by slash ('/', U+002F) characters, regardless
   159  // of host operating system convention.
   160  //
   161  // Each method has the same semantics as the os package's function of the same
   162  // name.
   163  //
   164  // Note that the os.Rename documentation says that "OS-specific restrictions
   165  // might apply". In particular, whether or not renaming a file or directory
   166  // overwriting another existing file or directory is an error is OS-dependent.
   167  type WebDAV struct {
   168  	*libhttp.Server
   169  	opt           Options
   170  	f             fs.Fs
   171  	_vfs          *vfs.VFS // don't use directly, use getVFS
   172  	webdavhandler *webdav.Handler
   173  	proxy         *proxy.Proxy
   174  	ctx           context.Context // for global config
   175  }
   176  
   177  // check interface
   178  var _ webdav.FileSystem = (*WebDAV)(nil)
   179  
   180  // Make a new WebDAV to serve the remote
   181  func newWebDAV(ctx context.Context, f fs.Fs, opt *Options) (w *WebDAV, err error) {
   182  	w = &WebDAV{
   183  		f:   f,
   184  		ctx: ctx,
   185  		opt: *opt,
   186  	}
   187  	if proxyflags.Opt.AuthProxy != "" {
   188  		w.proxy = proxy.New(ctx, &proxyflags.Opt)
   189  		// override auth
   190  		w.opt.Auth.CustomAuthFn = w.auth
   191  	} else {
   192  		w._vfs = vfs.New(f, &vfsflags.Opt)
   193  	}
   194  
   195  	w.Server, err = libhttp.NewServer(ctx,
   196  		libhttp.WithConfig(w.opt.HTTP),
   197  		libhttp.WithAuth(w.opt.Auth),
   198  		libhttp.WithTemplate(w.opt.Template),
   199  	)
   200  	if err != nil {
   201  		return nil, fmt.Errorf("failed to init server: %w", err)
   202  	}
   203  
   204  	webdavHandler := &webdav.Handler{
   205  		Prefix:     w.opt.HTTP.BaseURL,
   206  		FileSystem: w,
   207  		LockSystem: webdav.NewMemLS(),
   208  		Logger:     w.logRequest, // FIXME
   209  	}
   210  	w.webdavhandler = webdavHandler
   211  
   212  	router := w.Server.Router()
   213  	router.Use(
   214  		middleware.SetHeader("Accept-Ranges", "bytes"),
   215  		middleware.SetHeader("Server", "rclone/"+fs.Version),
   216  	)
   217  
   218  	router.Handle("/*", w)
   219  
   220  	// Webdav only methods not defined in chi
   221  	methods := []string{
   222  		"COPY",      // Copies the resource.
   223  		"LOCK",      // Locks the resource.
   224  		"MKCOL",     // Creates the collection specified.
   225  		"MOVE",      // Moves the resource.
   226  		"PROPFIND",  // Performs a property find on the server.
   227  		"PROPPATCH", // Sets or removes properties on the server.
   228  		"UNLOCK",    // Unlocks the resource.
   229  	}
   230  	for _, method := range methods {
   231  		chi.RegisterMethod(method)
   232  		router.Method(method, "/*", w)
   233  	}
   234  
   235  	return w, nil
   236  }
   237  
   238  // Gets the VFS in use for this request
   239  func (w *WebDAV) getVFS(ctx context.Context) (VFS *vfs.VFS, err error) {
   240  	if w._vfs != nil {
   241  		return w._vfs, nil
   242  	}
   243  	value := libhttp.CtxGetAuth(ctx)
   244  	if value == nil {
   245  		return nil, errors.New("no VFS found in context")
   246  	}
   247  	VFS, ok := value.(*vfs.VFS)
   248  	if !ok {
   249  		return nil, fmt.Errorf("context value is not VFS: %#v", value)
   250  	}
   251  	return VFS, nil
   252  }
   253  
   254  // auth does proxy authorization
   255  func (w *WebDAV) auth(user, pass string) (value interface{}, err error) {
   256  	VFS, _, err := w.proxy.Call(user, pass, false)
   257  	if err != nil {
   258  		return nil, err
   259  	}
   260  	return VFS, err
   261  }
   262  
   263  type webdavRW struct {
   264  	http.ResponseWriter
   265  	status int
   266  }
   267  
   268  func (rw *webdavRW) WriteHeader(statusCode int) {
   269  	rw.status = statusCode
   270  	rw.ResponseWriter.WriteHeader(statusCode)
   271  }
   272  
   273  func (rw *webdavRW) isSuccessfull() bool {
   274  	return rw.status == 0 || (rw.status >= 200 && rw.status <= 299)
   275  }
   276  
   277  func (w *WebDAV) postprocess(r *http.Request, remote string) {
   278  	// set modtime from requests, don't write to client because status is already written
   279  	switch r.Method {
   280  	case "COPY", "MOVE", "PUT":
   281  		VFS, err := w.getVFS(r.Context())
   282  		if err != nil {
   283  			fs.Errorf(nil, "Failed to get VFS: %v", err)
   284  			return
   285  		}
   286  
   287  		// Get the node
   288  		node, err := VFS.Stat(remote)
   289  		if err != nil {
   290  			fs.Errorf(nil, "Failed to stat node: %v", err)
   291  			return
   292  		}
   293  
   294  		mh := r.Header.Get("X-OC-Mtime")
   295  		if mh != "" {
   296  			modtimeUnix, err := strconv.ParseInt(mh, 10, 64)
   297  			if err == nil {
   298  				err = node.SetModTime(time.Unix(modtimeUnix, 0))
   299  				if err != nil {
   300  					fs.Errorf(nil, "Failed to set modtime: %v", err)
   301  				}
   302  			} else {
   303  				fs.Errorf(nil, "Failed to parse modtime: %v", err)
   304  			}
   305  		}
   306  	}
   307  }
   308  
   309  func (w *WebDAV) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
   310  	urlPath := r.URL.Path
   311  	isDir := strings.HasSuffix(urlPath, "/")
   312  	remote := strings.Trim(urlPath, "/")
   313  	if !w.opt.DisableGETDir && (r.Method == "GET" || r.Method == "HEAD") && isDir {
   314  		w.serveDir(rw, r, remote)
   315  		return
   316  	}
   317  	// Add URL Prefix back to path since webdavhandler needs to
   318  	// return absolute references.
   319  	r.URL.Path = w.opt.HTTP.BaseURL + r.URL.Path
   320  	wrw := &webdavRW{ResponseWriter: rw}
   321  	w.webdavhandler.ServeHTTP(wrw, r)
   322  
   323  	if wrw.isSuccessfull() {
   324  		w.postprocess(r, remote)
   325  	}
   326  }
   327  
   328  // serveDir serves a directory index at dirRemote
   329  // This is similar to serveDir in serve http.
   330  func (w *WebDAV) serveDir(rw http.ResponseWriter, r *http.Request, dirRemote string) {
   331  	VFS, err := w.getVFS(r.Context())
   332  	if err != nil {
   333  		http.Error(rw, "Root directory not found", http.StatusNotFound)
   334  		fs.Errorf(nil, "Failed to serve directory: %v", err)
   335  		return
   336  	}
   337  	// List the directory
   338  	node, err := VFS.Stat(dirRemote)
   339  	if err == vfs.ENOENT {
   340  		http.Error(rw, "Directory not found", http.StatusNotFound)
   341  		return
   342  	} else if err != nil {
   343  		serve.Error(dirRemote, rw, "Failed to list directory", err)
   344  		return
   345  	}
   346  	if !node.IsDir() {
   347  		http.Error(rw, "Not a directory", http.StatusNotFound)
   348  		return
   349  	}
   350  	dir := node.(*vfs.Dir)
   351  	dirEntries, err := dir.ReadDirAll()
   352  
   353  	if err != nil {
   354  		serve.Error(dirRemote, rw, "Failed to list directory", err)
   355  		return
   356  	}
   357  
   358  	// Make the entries for display
   359  	directory := serve.NewDirectory(dirRemote, w.Server.HTMLTemplate())
   360  	for _, node := range dirEntries {
   361  		if vfsflags.Opt.NoModTime {
   362  			directory.AddHTMLEntry(node.Path(), node.IsDir(), node.Size(), time.Time{})
   363  		} else {
   364  			directory.AddHTMLEntry(node.Path(), node.IsDir(), node.Size(), node.ModTime().UTC())
   365  		}
   366  	}
   367  
   368  	sortParm := r.URL.Query().Get("sort")
   369  	orderParm := r.URL.Query().Get("order")
   370  	directory.ProcessQueryParams(sortParm, orderParm)
   371  
   372  	directory.Serve(rw, r)
   373  }
   374  
   375  // serve runs the http server in the background.
   376  //
   377  // Use s.Close() and s.Wait() to shutdown server
   378  func (w *WebDAV) serve() error {
   379  	w.Serve()
   380  	fs.Logf(w.f, "WebDav Server started on %s", w.URLs())
   381  	return nil
   382  }
   383  
   384  // logRequest is called by the webdav module on every request
   385  func (w *WebDAV) logRequest(r *http.Request, err error) {
   386  	fs.Infof(r.URL.Path, "%s from %s", r.Method, r.RemoteAddr)
   387  }
   388  
   389  // Mkdir creates a directory
   390  func (w *WebDAV) Mkdir(ctx context.Context, name string, perm os.FileMode) (err error) {
   391  	// defer log.Trace(name, "perm=%v", perm)("err = %v", &err)
   392  	VFS, err := w.getVFS(ctx)
   393  	if err != nil {
   394  		return err
   395  	}
   396  	dir, leaf, err := VFS.StatParent(name)
   397  	if err != nil {
   398  		return err
   399  	}
   400  	_, err = dir.Mkdir(leaf)
   401  	return err
   402  }
   403  
   404  // OpenFile opens a file or a directory
   405  func (w *WebDAV) OpenFile(ctx context.Context, name string, flags int, perm os.FileMode) (file webdav.File, err error) {
   406  	// defer log.Trace(name, "flags=%v, perm=%v", flags, perm)("err = %v", &err)
   407  	VFS, err := w.getVFS(ctx)
   408  	if err != nil {
   409  		return nil, err
   410  	}
   411  	f, err := VFS.OpenFile(name, flags, perm)
   412  	if err != nil {
   413  		return nil, err
   414  	}
   415  	return Handle{Handle: f, w: w, ctx: ctx}, nil
   416  }
   417  
   418  // RemoveAll removes a file or a directory and its contents
   419  func (w *WebDAV) RemoveAll(ctx context.Context, name string) (err error) {
   420  	// defer log.Trace(name, "")("err = %v", &err)
   421  	VFS, err := w.getVFS(ctx)
   422  	if err != nil {
   423  		return err
   424  	}
   425  	node, err := VFS.Stat(name)
   426  	if err != nil {
   427  		return err
   428  	}
   429  	err = node.RemoveAll()
   430  	if err != nil {
   431  		return err
   432  	}
   433  	return nil
   434  }
   435  
   436  // Rename a file or a directory
   437  func (w *WebDAV) Rename(ctx context.Context, oldName, newName string) (err error) {
   438  	// defer log.Trace(oldName, "newName=%q", newName)("err = %v", &err)
   439  	VFS, err := w.getVFS(ctx)
   440  	if err != nil {
   441  		return err
   442  	}
   443  	return VFS.Rename(oldName, newName)
   444  }
   445  
   446  // Stat returns info about the file or directory
   447  func (w *WebDAV) Stat(ctx context.Context, name string) (fi os.FileInfo, err error) {
   448  	// defer log.Trace(name, "")("fi=%+v, err = %v", &fi, &err)
   449  	VFS, err := w.getVFS(ctx)
   450  	if err != nil {
   451  		return nil, err
   452  	}
   453  	fi, err = VFS.Stat(name)
   454  	if err != nil {
   455  		return nil, err
   456  	}
   457  	return FileInfo{FileInfo: fi, w: w}, nil
   458  }
   459  
   460  // Handle represents an open file
   461  type Handle struct {
   462  	vfs.Handle
   463  	w   *WebDAV
   464  	ctx context.Context
   465  }
   466  
   467  // Readdir reads directory entries from the handle
   468  func (h Handle) Readdir(count int) (fis []os.FileInfo, err error) {
   469  	fis, err = h.Handle.Readdir(count)
   470  	if err != nil {
   471  		return nil, err
   472  	}
   473  	// Wrap each FileInfo
   474  	for i := range fis {
   475  		fis[i] = FileInfo{FileInfo: fis[i], w: h.w}
   476  	}
   477  	return fis, nil
   478  }
   479  
   480  // Stat the handle
   481  func (h Handle) Stat() (fi os.FileInfo, err error) {
   482  	fi, err = h.Handle.Stat()
   483  	if err != nil {
   484  		return nil, err
   485  	}
   486  	return FileInfo{FileInfo: fi, w: h.w}, nil
   487  }
   488  
   489  // DeadProps returns extra properties about the handle
   490  func (h Handle) DeadProps() (map[xml.Name]webdav.Property, error) {
   491  	var (
   492  		xmlName    xml.Name
   493  		property   webdav.Property
   494  		properties = make(map[xml.Name]webdav.Property)
   495  	)
   496  	if h.w.opt.HashType != hash.None {
   497  		entry := h.Handle.Node().DirEntry()
   498  		if o, ok := entry.(fs.Object); ok {
   499  			hash, err := o.Hash(h.ctx, h.w.opt.HashType)
   500  			if err == nil {
   501  				xmlName.Space = "http://owncloud.org/ns"
   502  				xmlName.Local = "checksums"
   503  				property.XMLName = xmlName
   504  				property.InnerXML = append(property.InnerXML, "<checksum xmlns=\"http://owncloud.org/ns\">"...)
   505  				property.InnerXML = append(property.InnerXML, strings.ToUpper(h.w.opt.HashType.String())...)
   506  				property.InnerXML = append(property.InnerXML, ':')
   507  				property.InnerXML = append(property.InnerXML, hash...)
   508  				property.InnerXML = append(property.InnerXML, "</checksum>"...)
   509  				properties[xmlName] = property
   510  			} else {
   511  				fs.Errorf(nil, "failed to calculate hash: %v", err)
   512  			}
   513  		}
   514  	}
   515  
   516  	xmlName.Space = "DAV:"
   517  	xmlName.Local = "lastmodified"
   518  	property.XMLName = xmlName
   519  	property.InnerXML = strconv.AppendInt(nil, h.Handle.Node().ModTime().Unix(), 10)
   520  	properties[xmlName] = property
   521  
   522  	return properties, nil
   523  }
   524  
   525  // Patch changes modtime of the underlying resources, it returns ok for all properties, the error is from setModtime if any
   526  // FIXME does not check for invalid property and SetModTime error
   527  func (h Handle) Patch(proppatches []webdav.Proppatch) ([]webdav.Propstat, error) {
   528  	var (
   529  		stat webdav.Propstat
   530  		err  error
   531  	)
   532  	stat.Status = http.StatusOK
   533  	for _, patch := range proppatches {
   534  		for _, prop := range patch.Props {
   535  			stat.Props = append(stat.Props, webdav.Property{XMLName: prop.XMLName})
   536  			if prop.XMLName.Space == "DAV:" && prop.XMLName.Local == "lastmodified" {
   537  				var modtimeUnix int64
   538  				modtimeUnix, err = strconv.ParseInt(string(prop.InnerXML), 10, 64)
   539  				if err == nil {
   540  					err = h.Handle.Node().SetModTime(time.Unix(modtimeUnix, 0))
   541  				}
   542  			}
   543  		}
   544  	}
   545  	return []webdav.Propstat{stat}, err
   546  }
   547  
   548  // FileInfo represents info about a file satisfying os.FileInfo and
   549  // also some additional interfaces for webdav for ETag and ContentType
   550  type FileInfo struct {
   551  	os.FileInfo
   552  	w *WebDAV
   553  }
   554  
   555  // ETag returns an ETag for the FileInfo
   556  func (fi FileInfo) ETag(ctx context.Context) (etag string, err error) {
   557  	// defer log.Trace(fi, "")("etag=%q, err=%v", &etag, &err)
   558  	if fi.w.opt.HashType == hash.None {
   559  		return "", webdav.ErrNotImplemented
   560  	}
   561  	node, ok := (fi.FileInfo).(vfs.Node)
   562  	if !ok {
   563  		fs.Errorf(fi, "Expecting vfs.Node, got %T", fi.FileInfo)
   564  		return "", webdav.ErrNotImplemented
   565  	}
   566  	entry := node.DirEntry()
   567  	o, ok := entry.(fs.Object)
   568  	if !ok {
   569  		return "", webdav.ErrNotImplemented
   570  	}
   571  	hash, err := o.Hash(ctx, fi.w.opt.HashType)
   572  	if err != nil || hash == "" {
   573  		return "", webdav.ErrNotImplemented
   574  	}
   575  	return `"` + hash + `"`, nil
   576  }
   577  
   578  // ContentType returns a content type for the FileInfo
   579  func (fi FileInfo) ContentType(ctx context.Context) (contentType string, err error) {
   580  	// defer log.Trace(fi, "")("etag=%q, err=%v", &contentType, &err)
   581  	node, ok := (fi.FileInfo).(vfs.Node)
   582  	if !ok {
   583  		fs.Errorf(fi, "Expecting vfs.Node, got %T", fi.FileInfo)
   584  		return "application/octet-stream", nil
   585  	}
   586  	entry := node.DirEntry() // can be nil
   587  	switch x := entry.(type) {
   588  	case fs.Object:
   589  		return fs.MimeType(ctx, x), nil
   590  	case fs.Directory:
   591  		return "inode/directory", nil
   592  	case nil:
   593  		return mime.TypeByExtension(path.Ext(node.Name())), nil
   594  	}
   595  	fs.Errorf(fi, "Expecting fs.Object or fs.Directory, got %T", entry)
   596  	return "application/octet-stream", nil
   597  }