13111: Merge branch 'master' into 13111-webdav-projects
authorTom Clegg <tclegg@veritasgenetics.com>
Thu, 5 Apr 2018 20:14:35 +0000 (16:14 -0400)
committerTom Clegg <tclegg@veritasgenetics.com>
Thu, 5 Apr 2018 20:14:35 +0000 (16:14 -0400)
Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg@veritasgenetics.com>

20 files changed:
sdk/go/arvados/collection.go
sdk/go/arvados/fs_backend.go [new file with mode: 0644]
sdk/go/arvados/fs_base.go [new file with mode: 0644]
sdk/go/arvados/fs_collection.go [moved from sdk/go/arvados/collection_fs.go with 60% similarity]
sdk/go/arvados/fs_collection_test.go [moved from sdk/go/arvados/collection_fs_test.go with 99% similarity]
sdk/go/arvados/fs_deferred.go [new file with mode: 0644]
sdk/go/arvados/fs_filehandle.go [new file with mode: 0644]
sdk/go/arvados/fs_getternode.go [new file with mode: 0644]
sdk/go/arvados/fs_project.go [new file with mode: 0644]
sdk/go/arvados/fs_project_test.go [new file with mode: 0644]
sdk/go/arvados/fs_site.go [new file with mode: 0644]
sdk/go/arvados/fs_site_test.go [new file with mode: 0644]
sdk/go/arvados/fs_users.go [new file with mode: 0644]
sdk/go/arvadostest/fixtures.go
services/api/app/controllers/arvados/v1/users_controller.rb
services/api/test/functional/arvados/v1/users_controller_test.rb
services/keep-web/cadaver_test.go
services/keep-web/handler.go
services/keep-web/handler_test.go
services/keep-web/webdav.go

index 999b4e9d483454ace177cad829e90f85ddccc44c..aea0cc043f40f6460feaa6ecc3f26408cdc54e18 100644 (file)
@@ -16,6 +16,7 @@ import (
 // Collection is an arvados#collection resource.
 type Collection struct {
        UUID                   string     `json:"uuid,omitempty"`
+       OwnerUUID              string     `json:"owner_uuid,omitempty"`
        TrashAt                *time.Time `json:"trash_at,omitempty"`
        ManifestText           string     `json:"manifest_text,omitempty"`
        UnsignedManifestText   string     `json:"unsigned_manifest_text,omitempty"`
diff --git a/sdk/go/arvados/fs_backend.go b/sdk/go/arvados/fs_backend.go
new file mode 100644 (file)
index 0000000..301f0b4
--- /dev/null
@@ -0,0 +1,29 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package arvados
+
+import "io"
+
+type fsBackend interface {
+       keepClient
+       apiClient
+}
+
+// Ideally *Client would do everything; meanwhile keepBackend
+// implements fsBackend by merging the two kinds of arvados client.
+type keepBackend struct {
+       keepClient
+       apiClient
+}
+
+type keepClient interface {
+       ReadAt(locator string, p []byte, off int) (int, error)
+       PutB(p []byte) (string, int, error)
+}
+
+type apiClient interface {
+       RequestAndDecode(dst interface{}, method, path string, body io.Reader, params interface{}) error
+       UpdateBody(rsc resource) io.Reader
+}
diff --git a/sdk/go/arvados/fs_base.go b/sdk/go/arvados/fs_base.go
new file mode 100644 (file)
index 0000000..369b4bb
--- /dev/null
@@ -0,0 +1,588 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package arvados
+
+import (
+       "errors"
+       "fmt"
+       "io"
+       "log"
+       "net/http"
+       "os"
+       "path"
+       "strings"
+       "sync"
+       "time"
+)
+
+var (
+       ErrReadOnlyFile      = errors.New("read-only file")
+       ErrNegativeOffset    = errors.New("cannot seek to negative offset")
+       ErrFileExists        = errors.New("file exists")
+       ErrInvalidOperation  = errors.New("invalid operation")
+       ErrInvalidArgument   = errors.New("invalid argument")
+       ErrDirectoryNotEmpty = errors.New("directory not empty")
+       ErrWriteOnlyMode     = errors.New("file is O_WRONLY")
+       ErrSyncNotSupported  = errors.New("O_SYNC flag is not supported")
+       ErrIsDirectory       = errors.New("cannot rename file to overwrite existing directory")
+       ErrNotADirectory     = errors.New("not a directory")
+       ErrPermission        = os.ErrPermission
+)
+
+// A File is an *os.File-like interface for reading and writing files
+// in a FileSystem.
+type File interface {
+       io.Reader
+       io.Writer
+       io.Closer
+       io.Seeker
+       Size() int64
+       Readdir(int) ([]os.FileInfo, error)
+       Stat() (os.FileInfo, error)
+       Truncate(int64) error
+       Sync() error
+}
+
+// A FileSystem is an http.Filesystem plus Stat() and support for
+// opening writable files. All methods are safe to call from multiple
+// goroutines.
+type FileSystem interface {
+       http.FileSystem
+       fsBackend
+
+       rootnode() inode
+
+       // filesystem-wide lock: used by Rename() to prevent deadlock
+       // while locking multiple inodes.
+       locker() sync.Locker
+
+       // create a new node with nil parent.
+       newNode(name string, perm os.FileMode, modTime time.Time) (node inode, err error)
+
+       // analogous to os.Stat()
+       Stat(name string) (os.FileInfo, error)
+
+       // analogous to os.Create(): create/truncate a file and open it O_RDWR.
+       Create(name string) (File, error)
+
+       // Like os.OpenFile(): create or open a file or directory.
+       //
+       // If flag&os.O_EXCL==0, it opens an existing file or
+       // directory if one exists. If flag&os.O_CREATE!=0, it creates
+       // a new empty file or directory if one does not already
+       // exist.
+       //
+       // When creating a new item, perm&os.ModeDir determines
+       // whether it is a file or a directory.
+       //
+       // A file can be opened multiple times and used concurrently
+       // from multiple goroutines. However, each File object should
+       // be used by only one goroutine at a time.
+       OpenFile(name string, flag int, perm os.FileMode) (File, error)
+
+       Mkdir(name string, perm os.FileMode) error
+       Remove(name string) error
+       RemoveAll(name string) error
+       Rename(oldname, newname string) error
+       Sync() error
+}
+
+type inode interface {
+       SetParent(parent inode, name string)
+       Parent() inode
+       FS() FileSystem
+       Read([]byte, filenodePtr) (int, filenodePtr, error)
+       Write([]byte, filenodePtr) (int, filenodePtr, error)
+       Truncate(int64) error
+       IsDir() bool
+       Readdir() ([]os.FileInfo, error)
+       Size() int64
+       FileInfo() os.FileInfo
+
+       // Child() performs lookups and updates of named child nodes.
+       //
+       // If replace is non-nil, Child calls replace(x) where x is
+       // the current child inode with the given name. If possible,
+       // the child inode is replaced with the one returned by
+       // replace().
+       //
+       // If replace(x) returns an inode (besides x or nil) that is
+       // subsequently returned by Child(), then Child()'s caller
+       // must ensure the new child's name and parent are set/updated
+       // to Child()'s name argument and its receiver respectively.
+       // This is not necessarily done before replace(x) returns, but
+       // it must be done before Child()'s caller releases the
+       // parent's lock.
+       //
+       // Nil represents "no child". replace(nil) signifies that no
+       // child with this name exists yet. If replace() returns nil,
+       // the existing child should be deleted if possible.
+       //
+       // An implementation of Child() is permitted to ignore
+       // replace() or its return value. For example, a regular file
+       // inode does not have children, so Child() always returns
+       // nil.
+       //
+       // Child() returns the child, if any, with the given name: if
+       // a child was added or changed, the new child is returned.
+       //
+       // Caller must have lock (or rlock if replace is nil).
+       Child(name string, replace func(inode) (inode, error)) (inode, error)
+
+       sync.Locker
+       RLock()
+       RUnlock()
+}
+
+type fileinfo struct {
+       name    string
+       mode    os.FileMode
+       size    int64
+       modTime time.Time
+}
+
+// Name implements os.FileInfo.
+func (fi fileinfo) Name() string {
+       return fi.name
+}
+
+// ModTime implements os.FileInfo.
+func (fi fileinfo) ModTime() time.Time {
+       return fi.modTime
+}
+
+// Mode implements os.FileInfo.
+func (fi fileinfo) Mode() os.FileMode {
+       return fi.mode
+}
+
+// IsDir implements os.FileInfo.
+func (fi fileinfo) IsDir() bool {
+       return fi.mode&os.ModeDir != 0
+}
+
+// Size implements os.FileInfo.
+func (fi fileinfo) Size() int64 {
+       return fi.size
+}
+
+// Sys implements os.FileInfo.
+func (fi fileinfo) Sys() interface{} {
+       return nil
+}
+
+type nullnode struct{}
+
+func (*nullnode) Mkdir(string, os.FileMode) error {
+       return ErrInvalidOperation
+}
+
+func (*nullnode) Read([]byte, filenodePtr) (int, filenodePtr, error) {
+       return 0, filenodePtr{}, ErrInvalidOperation
+}
+
+func (*nullnode) Write([]byte, filenodePtr) (int, filenodePtr, error) {
+       return 0, filenodePtr{}, ErrInvalidOperation
+}
+
+func (*nullnode) Truncate(int64) error {
+       return ErrInvalidOperation
+}
+
+func (*nullnode) FileInfo() os.FileInfo {
+       return fileinfo{}
+}
+
+func (*nullnode) IsDir() bool {
+       return false
+}
+
+func (*nullnode) Readdir() ([]os.FileInfo, error) {
+       return nil, ErrInvalidOperation
+}
+
+func (*nullnode) Child(name string, replace func(inode) (inode, error)) (inode, error) {
+       return nil, ErrNotADirectory
+}
+
+type treenode struct {
+       fs       FileSystem
+       parent   inode
+       inodes   map[string]inode
+       fileinfo fileinfo
+       sync.RWMutex
+       nullnode
+}
+
+func (n *treenode) FS() FileSystem {
+       return n.fs
+}
+
+func (n *treenode) SetParent(p inode, name string) {
+       n.Lock()
+       defer n.Unlock()
+       n.parent = p
+       n.fileinfo.name = name
+}
+
+func (n *treenode) Parent() inode {
+       n.RLock()
+       defer n.RUnlock()
+       return n.parent
+}
+
+func (n *treenode) IsDir() bool {
+       return true
+}
+
+func (n *treenode) Child(name string, replace func(inode) (inode, error)) (child inode, err error) {
+       child = n.inodes[name]
+       if name == "" || name == "." || name == ".." {
+               err = ErrInvalidArgument
+               return
+       }
+       if replace == nil {
+               return
+       }
+       newchild, err := replace(child)
+       if err != nil {
+               return
+       }
+       if newchild == nil {
+               delete(n.inodes, name)
+       } else if newchild != child {
+               n.inodes[name] = newchild
+               n.fileinfo.modTime = time.Now()
+               child = newchild
+       }
+       return
+}
+
+func (n *treenode) Size() int64 {
+       return n.FileInfo().Size()
+}
+
+func (n *treenode) FileInfo() os.FileInfo {
+       n.Lock()
+       defer n.Unlock()
+       n.fileinfo.size = int64(len(n.inodes))
+       return n.fileinfo
+}
+
+func (n *treenode) Readdir() (fi []os.FileInfo, err error) {
+       n.RLock()
+       defer n.RUnlock()
+       fi = make([]os.FileInfo, 0, len(n.inodes))
+       for _, inode := range n.inodes {
+               fi = append(fi, inode.FileInfo())
+       }
+       return
+}
+
+type fileSystem struct {
+       root inode
+       fsBackend
+       mutex sync.Mutex
+}
+
+func (fs *fileSystem) rootnode() inode {
+       return fs.root
+}
+
+func (fs *fileSystem) locker() sync.Locker {
+       return &fs.mutex
+}
+
+// OpenFile is analogous to os.OpenFile().
+func (fs *fileSystem) OpenFile(name string, flag int, perm os.FileMode) (File, error) {
+       return fs.openFile(name, flag, perm)
+}
+
+func (fs *fileSystem) openFile(name string, flag int, perm os.FileMode) (*filehandle, error) {
+       if flag&os.O_SYNC != 0 {
+               return nil, ErrSyncNotSupported
+       }
+       dirname, name := path.Split(name)
+       parent, err := rlookup(fs.root, dirname)
+       if err != nil {
+               return nil, err
+       }
+       var readable, writable bool
+       switch flag & (os.O_RDWR | os.O_RDONLY | os.O_WRONLY) {
+       case os.O_RDWR:
+               readable = true
+               writable = true
+       case os.O_RDONLY:
+               readable = true
+       case os.O_WRONLY:
+               writable = true
+       default:
+               return nil, fmt.Errorf("invalid flags 0x%x", flag)
+       }
+       if !writable && parent.IsDir() {
+               // A directory can be opened via "foo/", "foo/.", or
+               // "foo/..".
+               switch name {
+               case ".", "":
+                       return &filehandle{inode: parent}, nil
+               case "..":
+                       return &filehandle{inode: parent.Parent()}, nil
+               }
+       }
+       createMode := flag&os.O_CREATE != 0
+       if createMode {
+               parent.Lock()
+               defer parent.Unlock()
+       } else {
+               parent.RLock()
+               defer parent.RUnlock()
+       }
+       n, err := parent.Child(name, nil)
+       if err != nil {
+               return nil, err
+       } else if n == nil {
+               if !createMode {
+                       return nil, os.ErrNotExist
+               }
+               n, err = parent.Child(name, func(inode) (repl inode, err error) {
+                       repl, err = parent.FS().newNode(name, perm|0755, time.Now())
+                       if err != nil {
+                               return
+                       }
+                       repl.SetParent(parent, name)
+                       return
+               })
+               if err != nil {
+                       return nil, err
+               } else if n == nil {
+                       // Parent rejected new child, but returned no error
+                       return nil, ErrInvalidArgument
+               }
+       } else if flag&os.O_EXCL != 0 {
+               return nil, ErrFileExists
+       } else if flag&os.O_TRUNC != 0 {
+               if !writable {
+                       return nil, fmt.Errorf("invalid flag O_TRUNC in read-only mode")
+               } else if n.IsDir() {
+                       return nil, fmt.Errorf("invalid flag O_TRUNC when opening directory")
+               } else if err := n.Truncate(0); err != nil {
+                       return nil, err
+               }
+       }
+       return &filehandle{
+               inode:    n,
+               append:   flag&os.O_APPEND != 0,
+               readable: readable,
+               writable: writable,
+       }, nil
+}
+
+func (fs *fileSystem) Open(name string) (http.File, error) {
+       return fs.OpenFile(name, os.O_RDONLY, 0)
+}
+
+func (fs *fileSystem) Create(name string) (File, error) {
+       return fs.OpenFile(name, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0)
+}
+
+func (fs *fileSystem) Mkdir(name string, perm os.FileMode) error {
+       dirname, name := path.Split(name)
+       n, err := rlookup(fs.root, dirname)
+       if err != nil {
+               return err
+       }
+       n.Lock()
+       defer n.Unlock()
+       if child, err := n.Child(name, nil); err != nil {
+               return err
+       } else if child != nil {
+               return os.ErrExist
+       }
+
+       _, err = n.Child(name, func(inode) (repl inode, err error) {
+               repl, err = n.FS().newNode(name, perm|os.ModeDir, time.Now())
+               if err != nil {
+                       return
+               }
+               repl.SetParent(n, name)
+               return
+       })
+       return err
+}
+
+func (fs *fileSystem) Stat(name string) (os.FileInfo, error) {
+       node, err := rlookup(fs.root, name)
+       if err != nil {
+               return nil, err
+       }
+       return node.FileInfo(), nil
+}
+
+func (fs *fileSystem) Rename(oldname, newname string) error {
+       olddir, oldname := path.Split(oldname)
+       if oldname == "" || oldname == "." || oldname == ".." {
+               return ErrInvalidArgument
+       }
+       olddirf, err := fs.openFile(olddir+".", os.O_RDONLY, 0)
+       if err != nil {
+               return fmt.Errorf("%q: %s", olddir, err)
+       }
+       defer olddirf.Close()
+
+       newdir, newname := path.Split(newname)
+       if newname == "." || newname == ".." {
+               return ErrInvalidArgument
+       } else if newname == "" {
+               // Rename("a/b", "c/") means Rename("a/b", "c/b")
+               newname = oldname
+       }
+       newdirf, err := fs.openFile(newdir+".", os.O_RDONLY, 0)
+       if err != nil {
+               return fmt.Errorf("%q: %s", newdir, err)
+       }
+       defer newdirf.Close()
+
+       // TODO: If the nearest common ancestor ("nca") of olddirf and
+       // newdirf is on a different filesystem than fs, we should
+       // call nca.FS().Rename() instead of proceeding. Until then
+       // it's awkward for filesystems to implement their own Rename
+       // methods effectively: the only one that runs is the one on
+       // the root filesystem exposed to the caller (webdav, fuse,
+       // etc).
+
+       // When acquiring locks on multiple inodes, avoid deadlock by
+       // locking the entire containing filesystem first.
+       cfs := olddirf.inode.FS()
+       cfs.locker().Lock()
+       defer cfs.locker().Unlock()
+
+       if cfs != newdirf.inode.FS() {
+               // Moving inodes across filesystems is not (yet)
+               // supported. Locking inodes from different
+               // filesystems could deadlock, so we must error out
+               // now.
+               return ErrInvalidArgument
+       }
+
+       // To ensure we can test reliably whether we're about to move
+       // a directory into itself, lock all potential common
+       // ancestors of olddir and newdir.
+       needLock := []sync.Locker{}
+       for _, node := range []inode{olddirf.inode, newdirf.inode} {
+               needLock = append(needLock, node)
+               for node.Parent() != node && node.Parent().FS() == node.FS() {
+                       node = node.Parent()
+                       needLock = append(needLock, node)
+               }
+       }
+       locked := map[sync.Locker]bool{}
+       for i := len(needLock) - 1; i >= 0; i-- {
+               if n := needLock[i]; !locked[n] {
+                       n.Lock()
+                       defer n.Unlock()
+                       locked[n] = true
+               }
+       }
+
+       _, err = olddirf.inode.Child(oldname, func(oldinode inode) (inode, error) {
+               if oldinode == nil {
+                       return oldinode, os.ErrNotExist
+               }
+               if locked[oldinode] {
+                       // oldinode cannot become a descendant of itself.
+                       return oldinode, ErrInvalidArgument
+               }
+               if oldinode.FS() != cfs && newdirf.inode != olddirf.inode {
+                       // moving a mount point to a different parent
+                       // is not (yet) supported.
+                       return oldinode, ErrInvalidArgument
+               }
+               accepted, err := newdirf.inode.Child(newname, func(existing inode) (inode, error) {
+                       if existing != nil && existing.IsDir() {
+                               return existing, ErrIsDirectory
+                       }
+                       return oldinode, nil
+               })
+               if err != nil {
+                       // Leave oldinode in olddir.
+                       return oldinode, err
+               }
+               accepted.SetParent(newdirf.inode, newname)
+               return nil, nil
+       })
+       return err
+}
+
+func (fs *fileSystem) Remove(name string) error {
+       return fs.remove(strings.TrimRight(name, "/"), false)
+}
+
+func (fs *fileSystem) RemoveAll(name string) error {
+       err := fs.remove(strings.TrimRight(name, "/"), true)
+       if os.IsNotExist(err) {
+               // "If the path does not exist, RemoveAll returns
+               // nil." (see "os" pkg)
+               err = nil
+       }
+       return err
+}
+
+func (fs *fileSystem) remove(name string, recursive bool) error {
+       dirname, name := path.Split(name)
+       if name == "" || name == "." || name == ".." {
+               return ErrInvalidArgument
+       }
+       dir, err := rlookup(fs.root, dirname)
+       if err != nil {
+               return err
+       }
+       dir.Lock()
+       defer dir.Unlock()
+       _, err = dir.Child(name, func(node inode) (inode, error) {
+               if node == nil {
+                       return nil, os.ErrNotExist
+               }
+               if !recursive && node.IsDir() && node.Size() > 0 {
+                       return node, ErrDirectoryNotEmpty
+               }
+               return nil, nil
+       })
+       return err
+}
+
+func (fs *fileSystem) Sync() error {
+       log.Printf("TODO: sync fileSystem")
+       return ErrInvalidOperation
+}
+
+// rlookup (recursive lookup) returns the inode for the file/directory
+// with the given name (which may contain "/" separators). If no such
+// file/directory exists, the returned node is nil.
+func rlookup(start inode, path string) (node inode, err error) {
+       node = start
+       for _, name := range strings.Split(path, "/") {
+               if node.IsDir() {
+                       if name == "." || name == "" {
+                               continue
+                       }
+                       if name == ".." {
+                               node = node.Parent()
+                               continue
+                       }
+               }
+               node, err = func() (inode, error) {
+                       node.RLock()
+                       defer node.RUnlock()
+                       return node.Child(name, nil)
+               }()
+               if node == nil || err != nil {
+                       break
+               }
+       }
+       if node == nil && err == nil {
+               err = os.ErrNotExist
+       }
+       return
+}
similarity index 60%
rename from sdk/go/arvados/collection_fs.go
rename to sdk/go/arvados/fs_collection.go
index d8ee2a2b1c5175697bf39369274ff6c0a42e7310..923615ba768c003f957c70623c747dbb459b40e2 100644 (file)
@@ -5,10 +5,10 @@
 package arvados
 
 import (
-       "errors"
+       "encoding/json"
        "fmt"
        "io"
-       "net/http"
+       "log"
        "os"
        "path"
        "regexp"
@@ -19,107 +19,12 @@ import (
        "time"
 )
 
-var (
-       ErrReadOnlyFile      = errors.New("read-only file")
-       ErrNegativeOffset    = errors.New("cannot seek to negative offset")
-       ErrFileExists        = errors.New("file exists")
-       ErrInvalidOperation  = errors.New("invalid operation")
-       ErrInvalidArgument   = errors.New("invalid argument")
-       ErrDirectoryNotEmpty = errors.New("directory not empty")
-       ErrWriteOnlyMode     = errors.New("file is O_WRONLY")
-       ErrSyncNotSupported  = errors.New("O_SYNC flag is not supported")
-       ErrIsDirectory       = errors.New("cannot rename file to overwrite existing directory")
-       ErrPermission        = os.ErrPermission
+var maxBlockSize = 1 << 26
 
-       maxBlockSize = 1 << 26
-)
-
-// A File is an *os.File-like interface for reading and writing files
-// in a CollectionFileSystem.
-type File interface {
-       io.Reader
-       io.Writer
-       io.Closer
-       io.Seeker
-       Size() int64
-       Readdir(int) ([]os.FileInfo, error)
-       Stat() (os.FileInfo, error)
-       Truncate(int64) error
-}
-
-type keepClient interface {
-       ReadAt(locator string, p []byte, off int) (int, error)
-       PutB(p []byte) (string, int, error)
-}
-
-type fileinfo struct {
-       name    string
-       mode    os.FileMode
-       size    int64
-       modTime time.Time
-}
-
-// Name implements os.FileInfo.
-func (fi fileinfo) Name() string {
-       return fi.name
-}
-
-// ModTime implements os.FileInfo.
-func (fi fileinfo) ModTime() time.Time {
-       return fi.modTime
-}
-
-// Mode implements os.FileInfo.
-func (fi fileinfo) Mode() os.FileMode {
-       return fi.mode
-}
-
-// IsDir implements os.FileInfo.
-func (fi fileinfo) IsDir() bool {
-       return fi.mode&os.ModeDir != 0
-}
-
-// Size implements os.FileInfo.
-func (fi fileinfo) Size() int64 {
-       return fi.size
-}
-
-// Sys implements os.FileInfo.
-func (fi fileinfo) Sys() interface{} {
-       return nil
-}
-
-// A CollectionFileSystem is an http.Filesystem plus Stat() and
-// support for opening writable files. All methods are safe to call
-// from multiple goroutines.
+// A CollectionFileSystem is a FileSystem that can be serialized as a
+// manifest and stored as a collection.
 type CollectionFileSystem interface {
-       http.FileSystem
-
-       // analogous to os.Stat()
-       Stat(name string) (os.FileInfo, error)
-
-       // analogous to os.Create(): create/truncate a file and open it O_RDWR.
-       Create(name string) (File, error)
-
-       // Like os.OpenFile(): create or open a file or directory.
-       //
-       // If flag&os.O_EXCL==0, it opens an existing file or
-       // directory if one exists. If flag&os.O_CREATE!=0, it creates
-       // a new empty file or directory if one does not already
-       // exist.
-       //
-       // When creating a new item, perm&os.ModeDir determines
-       // whether it is a file or a directory.
-       //
-       // A file can be opened multiple times and used concurrently
-       // from multiple goroutines. However, each File object should
-       // be used by only one goroutine at a time.
-       OpenFile(name string, flag int, perm os.FileMode) (File, error)
-
-       Mkdir(name string, perm os.FileMode) error
-       Remove(name string) error
-       RemoveAll(name string) error
-       Rename(oldname, newname string) error
+       FileSystem
 
        // Flush all file data to Keep and return a snapshot of the
        // filesystem suitable for saving as (Collection)ManifestText.
@@ -128,55 +33,110 @@ type CollectionFileSystem interface {
        MarshalManifest(prefix string) (string, error)
 }
 
-type fileSystem struct {
-       dirnode
-}
-
-func (fs *fileSystem) OpenFile(name string, flag int, perm os.FileMode) (File, error) {
-       return fs.dirnode.OpenFile(name, flag, perm)
+type collectionFileSystem struct {
+       fileSystem
+       uuid string
 }
 
-func (fs *fileSystem) Open(name string) (http.File, error) {
-       return fs.dirnode.OpenFile(name, os.O_RDONLY, 0)
+// FileSystem returns a CollectionFileSystem for the collection.
+func (c *Collection) FileSystem(client apiClient, kc keepClient) (CollectionFileSystem, error) {
+       var modTime time.Time
+       if c.ModifiedAt == nil {
+               modTime = time.Now()
+       } else {
+               modTime = *c.ModifiedAt
+       }
+       fs := &collectionFileSystem{
+               uuid: c.UUID,
+               fileSystem: fileSystem{
+                       fsBackend: keepBackend{apiClient: client, keepClient: kc},
+               },
+       }
+       root := &dirnode{
+               fs: fs,
+               treenode: treenode{
+                       fileinfo: fileinfo{
+                               name:    ".",
+                               mode:    os.ModeDir | 0755,
+                               modTime: modTime,
+                       },
+                       inodes: make(map[string]inode),
+               },
+       }
+       root.SetParent(root, ".")
+       if err := root.loadManifest(c.ManifestText); err != nil {
+               return nil, err
+       }
+       backdateTree(root, modTime)
+       fs.root = root
+       return fs, nil
 }
 
-func (fs *fileSystem) Create(name string) (File, error) {
-       return fs.dirnode.OpenFile(name, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0)
+func backdateTree(n inode, modTime time.Time) {
+       switch n := n.(type) {
+       case *filenode:
+               n.fileinfo.modTime = modTime
+       case *dirnode:
+               n.fileinfo.modTime = modTime
+               for _, n := range n.inodes {
+                       backdateTree(n, modTime)
+               }
+       }
 }
 
-func (fs *fileSystem) Stat(name string) (fi os.FileInfo, err error) {
-       node := fs.dirnode.lookupPath(name)
-       if node == nil {
-               err = os.ErrNotExist
+func (fs *collectionFileSystem) newNode(name string, perm os.FileMode, modTime time.Time) (node inode, err error) {
+       if name == "" || name == "." || name == ".." {
+               return nil, ErrInvalidArgument
+       }
+       if perm.IsDir() {
+               return &dirnode{
+                       fs: fs,
+                       treenode: treenode{
+                               fileinfo: fileinfo{
+                                       name:    name,
+                                       mode:    perm | os.ModeDir,
+                                       modTime: modTime,
+                               },
+                               inodes: make(map[string]inode),
+                       },
+               }, nil
        } else {
-               fi = node.Stat()
+               return &filenode{
+                       fs: fs,
+                       fileinfo: fileinfo{
+                               name:    name,
+                               mode:    perm & ^os.ModeDir,
+                               modTime: modTime,
+                       },
+               }, nil
        }
-       return
 }
 
-type inode interface {
-       Parent() inode
-       Read([]byte, filenodePtr) (int, filenodePtr, error)
-       Write([]byte, filenodePtr) (int, filenodePtr, error)
-       Truncate(int64) error
-       Readdir() []os.FileInfo
-       Size() int64
-       Stat() os.FileInfo
-       sync.Locker
-       RLock()
-       RUnlock()
+func (fs *collectionFileSystem) Sync() error {
+       log.Printf("cfs.Sync()")
+       if fs.uuid == "" {
+               return nil
+       }
+       txt, err := fs.MarshalManifest(".")
+       if err != nil {
+               log.Printf("WARNING: (collectionFileSystem)Sync() failed: %s", err)
+               return err
+       }
+       coll := &Collection{
+               UUID:         fs.uuid,
+               ManifestText: txt,
+       }
+       err = fs.RequestAndDecode(nil, "PUT", "arvados/v1/collections/"+fs.uuid, fs.UpdateBody(coll), map[string]interface{}{"select": []string{"uuid"}})
+       if err != nil {
+               log.Printf("WARNING: (collectionFileSystem)Sync() failed: %s", err)
+       }
+       return err
 }
 
-// filenode implements inode.
-type filenode struct {
-       fileinfo fileinfo
-       parent   *dirnode
-       segments []segment
-       // number of times `segments` has changed in a
-       // way that might invalidate a filenodePtr
-       repacked int64
-       memsize  int64 // bytes in memSegments
-       sync.RWMutex
+func (fs *collectionFileSystem) MarshalManifest(prefix string) (string, error) {
+       fs.fileSystem.root.Lock()
+       defer fs.fileSystem.root.Unlock()
+       return fs.fileSystem.root.(*dirnode).marshalManifest(prefix)
 }
 
 // filenodePtr is an offset into a file that is (usually) efficient to
@@ -252,20 +212,41 @@ func (fn *filenode) seek(startPtr filenodePtr) (ptr filenodePtr) {
        return
 }
 
+// filenode implements inode.
+type filenode struct {
+       parent   inode
+       fs       FileSystem
+       fileinfo fileinfo
+       segments []segment
+       // number of times `segments` has changed in a
+       // way that might invalidate a filenodePtr
+       repacked int64
+       memsize  int64 // bytes in memSegments
+       sync.RWMutex
+       nullnode
+}
+
 // caller must have lock
 func (fn *filenode) appendSegment(e segment) {
        fn.segments = append(fn.segments, e)
        fn.fileinfo.size += int64(e.Len())
 }
 
+func (fn *filenode) SetParent(p inode, name string) {
+       fn.Lock()
+       defer fn.Unlock()
+       fn.parent = p
+       fn.fileinfo.name = name
+}
+
 func (fn *filenode) Parent() inode {
        fn.RLock()
        defer fn.RUnlock()
        return fn.parent
 }
 
-func (fn *filenode) Readdir() []os.FileInfo {
-       return nil
+func (fn *filenode) FS() FileSystem {
+       return fn.fs
 }
 
 // Read reads file data from a single segment, starting at startPtr,
@@ -302,7 +283,7 @@ func (fn *filenode) Size() int64 {
        return fn.fileinfo.Size()
 }
 
-func (fn *filenode) Stat() os.FileInfo {
+func (fn *filenode) FileInfo() os.FileInfo {
        fn.RLock()
        defer fn.RUnlock()
        return fn.fileinfo
@@ -513,7 +494,7 @@ func (fn *filenode) pruneMemSegments() {
                if !ok || seg.Len() < maxBlockSize {
                        continue
                }
-               locator, _, err := fn.parent.kc.PutB(seg.buf)
+               locator, _, err := fn.FS().PutB(seg.buf)
                if err != nil {
                        // TODO: stall (or return errors from)
                        // subsequent writes until flushing
@@ -522,7 +503,7 @@ func (fn *filenode) pruneMemSegments() {
                }
                fn.memsize -= int64(seg.Len())
                fn.segments[idx] = storedSegment{
-                       kc:      fn.parent.kc,
+                       kc:      fn.FS(),
                        locator: locator,
                        size:    seg.Len(),
                        offset:  0,
@@ -531,132 +512,34 @@ func (fn *filenode) pruneMemSegments() {
        }
 }
 
-// FileSystem returns a CollectionFileSystem for the collection.
-func (c *Collection) FileSystem(client *Client, kc keepClient) (CollectionFileSystem, error) {
-       var modTime time.Time
-       if c.ModifiedAt == nil {
-               modTime = time.Now()
-       } else {
-               modTime = *c.ModifiedAt
-       }
-       fs := &fileSystem{dirnode: dirnode{
-               client: client,
-               kc:     kc,
-               fileinfo: fileinfo{
-                       name:    ".",
-                       mode:    os.ModeDir | 0755,
-                       modTime: modTime,
-               },
-               parent: nil,
-               inodes: make(map[string]inode),
-       }}
-       fs.dirnode.parent = &fs.dirnode
-       if err := fs.dirnode.loadManifest(c.ManifestText); err != nil {
-               return nil, err
-       }
-       return fs, nil
-}
-
-type filehandle struct {
-       inode
-       ptr        filenodePtr
-       append     bool
-       readable   bool
-       writable   bool
-       unreaddirs []os.FileInfo
-}
-
-func (f *filehandle) Read(p []byte) (n int, err error) {
-       if !f.readable {
-               return 0, ErrWriteOnlyMode
-       }
-       f.inode.RLock()
-       defer f.inode.RUnlock()
-       n, f.ptr, err = f.inode.Read(p, f.ptr)
-       return
-}
-
-func (f *filehandle) Seek(off int64, whence int) (pos int64, err error) {
-       size := f.inode.Size()
-       ptr := f.ptr
-       switch whence {
-       case io.SeekStart:
-               ptr.off = off
-       case io.SeekCurrent:
-               ptr.off += off
-       case io.SeekEnd:
-               ptr.off = size + off
-       }
-       if ptr.off < 0 {
-               return f.ptr.off, ErrNegativeOffset
-       }
-       if ptr.off != f.ptr.off {
-               f.ptr = ptr
-               // force filenode to recompute f.ptr fields on next
-               // use
-               f.ptr.repacked = -1
-       }
-       return f.ptr.off, nil
-}
-
-func (f *filehandle) Truncate(size int64) error {
-       return f.inode.Truncate(size)
+type dirnode struct {
+       fs *collectionFileSystem
+       treenode
 }
 
-func (f *filehandle) Write(p []byte) (n int, err error) {
-       if !f.writable {
-               return 0, ErrReadOnlyFile
-       }
-       f.inode.Lock()
-       defer f.inode.Unlock()
-       if fn, ok := f.inode.(*filenode); ok && f.append {
-               f.ptr = filenodePtr{
-                       off:        fn.fileinfo.size,
-                       segmentIdx: len(fn.segments),
-                       segmentOff: 0,
-                       repacked:   fn.repacked,
-               }
-       }
-       n, f.ptr, err = f.inode.Write(p, f.ptr)
-       return
+func (dn *dirnode) FS() FileSystem {
+       return dn.fs
 }
 
-func (f *filehandle) Readdir(count int) ([]os.FileInfo, error) {
-       if !f.inode.Stat().IsDir() {
-               return nil, ErrInvalidOperation
-       }
-       if count <= 0 {
-               return f.inode.Readdir(), nil
-       }
-       if f.unreaddirs == nil {
-               f.unreaddirs = f.inode.Readdir()
-       }
-       if len(f.unreaddirs) == 0 {
-               return nil, io.EOF
-       }
-       if count > len(f.unreaddirs) {
-               count = len(f.unreaddirs)
+func (dn *dirnode) Child(name string, replace func(inode) (inode, error)) (inode, error) {
+       if dn == dn.fs.rootnode() && name == ".arvados#collection" {
+               gn := &getternode{Getter: func() ([]byte, error) {
+                       var coll Collection
+                       var err error
+                       coll.ManifestText, err = dn.fs.MarshalManifest(".")
+                       if err != nil {
+                               return nil, err
+                       }
+                       data, err := json.Marshal(&coll)
+                       if err == nil {
+                               data = append(data, '\n')
+                       }
+                       return data, err
+               }}
+               gn.SetParent(dn, name)
+               return gn, nil
        }
-       ret := f.unreaddirs[:count]
-       f.unreaddirs = f.unreaddirs[count:]
-       return ret, nil
-}
-
-func (f *filehandle) Stat() (os.FileInfo, error) {
-       return f.inode.Stat(), nil
-}
-
-func (f *filehandle) Close() error {
-       return nil
-}
-
-type dirnode struct {
-       fileinfo fileinfo
-       parent   *dirnode
-       client   *Client
-       kc       keepClient
-       inodes   map[string]inode
-       sync.RWMutex
+       return dn.treenode.Child(name, replace)
 }
 
 // sync flushes in-memory data (for all files in the tree rooted at
@@ -677,7 +560,7 @@ func (dn *dirnode) sync() error {
                for _, sb := range sbs {
                        block = append(block, sb.fn.segments[sb.idx].(*memSegment).buf...)
                }
-               locator, _, err := dn.kc.PutB(block)
+               locator, _, err := dn.fs.PutB(block)
                if err != nil {
                        return err
                }
@@ -685,7 +568,7 @@ func (dn *dirnode) sync() error {
                for _, sb := range sbs {
                        data := sb.fn.segments[sb.idx].(*memSegment).buf
                        sb.fn.segments[sb.idx] = storedSegment{
-                               kc:      dn.kc,
+                               kc:      dn.fs,
                                locator: locator,
                                size:    len(block),
                                offset:  off,
@@ -735,12 +618,6 @@ func (dn *dirnode) sync() error {
        return flush(pending)
 }
 
-func (dn *dirnode) MarshalManifest(prefix string) (string, error) {
-       dn.Lock()
-       defer dn.Unlock()
-       return dn.marshalManifest(prefix)
-}
-
 // caller must have read lock.
 func (dn *dirnode) marshalManifest(prefix string) (string, error) {
        var streamLen int64
@@ -912,7 +789,7 @@ func (dn *dirnode) loadManifest(txt string) error {
                                        blkLen = int(offset + length - pos - int64(blkOff))
                                }
                                fnode.appendSegment(storedSegment{
-                                       kc:      dn.kc,
+                                       kc:      dn.fs,
                                        locator: seg.locator,
                                        size:    seg.size,
                                        offset:  blkOff,
@@ -941,6 +818,7 @@ func (dn *dirnode) loadManifest(txt string) error {
 
 // only safe to call from loadManifest -- no locking
 func (dn *dirnode) createFileAndParents(path string) (fn *filenode, err error) {
+       var node inode = dn
        names := strings.Split(path, "/")
        basename := names[len(names)-1]
        if basename == "" || basename == "." || basename == ".." {
@@ -950,339 +828,55 @@ func (dn *dirnode) createFileAndParents(path string) (fn *filenode, err error) {
        for _, name := range names[:len(names)-1] {
                switch name {
                case "", ".":
+                       continue
                case "..":
-                       dn = dn.parent
-               default:
-                       switch node := dn.inodes[name].(type) {
-                       case nil:
-                               dn = dn.newDirnode(name, 0755, dn.fileinfo.modTime)
-                       case *dirnode:
-                               dn = node
-                       case *filenode:
-                               err = ErrFileExists
-                               return
+                       if node == dn {
+                               // can't be sure parent will be a *dirnode
+                               return nil, ErrInvalidArgument
                        }
-               }
-       }
-       switch node := dn.inodes[basename].(type) {
-       case nil:
-               fn = dn.newFilenode(basename, 0755, dn.fileinfo.modTime)
-       case *filenode:
-               fn = node
-       case *dirnode:
-               err = ErrIsDirectory
-       }
-       return
-}
-
-func (dn *dirnode) mkdir(name string) (*filehandle, error) {
-       return dn.OpenFile(name, os.O_CREATE|os.O_EXCL, os.ModeDir|0755)
-}
-
-func (dn *dirnode) Mkdir(name string, perm os.FileMode) error {
-       f, err := dn.mkdir(name)
-       if err == nil {
-               err = f.Close()
-       }
-       return err
-}
-
-func (dn *dirnode) Remove(name string) error {
-       return dn.remove(strings.TrimRight(name, "/"), false)
-}
-
-func (dn *dirnode) RemoveAll(name string) error {
-       err := dn.remove(strings.TrimRight(name, "/"), true)
-       if os.IsNotExist(err) {
-               // "If the path does not exist, RemoveAll returns
-               // nil." (see "os" pkg)
-               err = nil
-       }
-       return err
-}
-
-func (dn *dirnode) remove(name string, recursive bool) error {
-       dirname, name := path.Split(name)
-       if name == "" || name == "." || name == ".." {
-               return ErrInvalidArgument
-       }
-       dn, ok := dn.lookupPath(dirname).(*dirnode)
-       if !ok {
-               return os.ErrNotExist
-       }
-       dn.Lock()
-       defer dn.Unlock()
-       switch node := dn.inodes[name].(type) {
-       case nil:
-               return os.ErrNotExist
-       case *dirnode:
-               node.RLock()
-               defer node.RUnlock()
-               if !recursive && len(node.inodes) > 0 {
-                       return ErrDirectoryNotEmpty
-               }
-       }
-       delete(dn.inodes, name)
-       return nil
-}
-
-func (dn *dirnode) Rename(oldname, newname string) error {
-       olddir, oldname := path.Split(oldname)
-       if oldname == "" || oldname == "." || oldname == ".." {
-               return ErrInvalidArgument
-       }
-       olddirf, err := dn.OpenFile(olddir+".", os.O_RDONLY, 0)
-       if err != nil {
-               return fmt.Errorf("%q: %s", olddir, err)
-       }
-       defer olddirf.Close()
-       newdir, newname := path.Split(newname)
-       if newname == "." || newname == ".." {
-               return ErrInvalidArgument
-       } else if newname == "" {
-               // Rename("a/b", "c/") means Rename("a/b", "c/b")
-               newname = oldname
-       }
-       newdirf, err := dn.OpenFile(newdir+".", os.O_RDONLY, 0)
-       if err != nil {
-               return fmt.Errorf("%q: %s", newdir, err)
-       }
-       defer newdirf.Close()
-
-       // When acquiring locks on multiple nodes, all common
-       // ancestors must be locked first in order to avoid
-       // deadlock. This is assured by locking the path from root to
-       // newdir, then locking the path from root to olddir, skipping
-       // any already-locked nodes.
-       needLock := []sync.Locker{}
-       for _, f := range []*filehandle{olddirf, newdirf} {
-               node := f.inode
-               needLock = append(needLock, node)
-               for node.Parent() != node {
                        node = node.Parent()
-                       needLock = append(needLock, node)
-               }
-       }
-       locked := map[sync.Locker]bool{}
-       for i := len(needLock) - 1; i >= 0; i-- {
-               if n := needLock[i]; !locked[n] {
-                       n.Lock()
-                       defer n.Unlock()
-                       locked[n] = true
+                       continue
                }
-       }
-
-       olddn := olddirf.inode.(*dirnode)
-       newdn := newdirf.inode.(*dirnode)
-       oldinode, ok := olddn.inodes[oldname]
-       if !ok {
-               return os.ErrNotExist
-       }
-       if locked[oldinode] {
-               // oldinode cannot become a descendant of itself.
-               return ErrInvalidArgument
-       }
-       if existing, ok := newdn.inodes[newname]; ok {
-               // overwriting an existing file or dir
-               if dn, ok := existing.(*dirnode); ok {
-                       if !oldinode.Stat().IsDir() {
-                               return ErrIsDirectory
-                       }
-                       dn.RLock()
-                       defer dn.RUnlock()
-                       if len(dn.inodes) > 0 {
-                               return ErrDirectoryNotEmpty
+               node, err = node.Child(name, func(child inode) (inode, error) {
+                       if child == nil {
+                               child, err := node.FS().newNode(name, 0755|os.ModeDir, node.Parent().FileInfo().ModTime())
+                               if err != nil {
+                                       return nil, err
+                               }
+                               child.SetParent(node, name)
+                               return child, nil
+                       } else if !child.IsDir() {
+                               return child, ErrFileExists
+                       } else {
+                               return child, nil
                        }
+               })
+               if err != nil {
+                       return
                }
-       } else {
-               if newdn.inodes == nil {
-                       newdn.inodes = make(map[string]inode)
-               }
-               newdn.fileinfo.size++
        }
-       newdn.inodes[newname] = oldinode
-       switch n := oldinode.(type) {
-       case *dirnode:
-               n.parent = newdn
-       case *filenode:
-               n.parent = newdn
-       default:
-               panic(fmt.Sprintf("bad inode type %T", n))
-       }
-       delete(olddn.inodes, oldname)
-       olddn.fileinfo.size--
-       return nil
-}
-
-func (dn *dirnode) Parent() inode {
-       dn.RLock()
-       defer dn.RUnlock()
-       return dn.parent
-}
-
-func (dn *dirnode) Readdir() (fi []os.FileInfo) {
-       dn.RLock()
-       defer dn.RUnlock()
-       fi = make([]os.FileInfo, 0, len(dn.inodes))
-       for _, inode := range dn.inodes {
-               fi = append(fi, inode.Stat())
-       }
-       return
-}
-
-func (dn *dirnode) Read(p []byte, ptr filenodePtr) (int, filenodePtr, error) {
-       return 0, ptr, ErrInvalidOperation
-}
-
-func (dn *dirnode) Write(p []byte, ptr filenodePtr) (int, filenodePtr, error) {
-       return 0, ptr, ErrInvalidOperation
-}
-
-func (dn *dirnode) Size() int64 {
-       dn.RLock()
-       defer dn.RUnlock()
-       return dn.fileinfo.Size()
-}
-
-func (dn *dirnode) Stat() os.FileInfo {
-       dn.RLock()
-       defer dn.RUnlock()
-       return dn.fileinfo
-}
-
-func (dn *dirnode) Truncate(int64) error {
-       return ErrInvalidOperation
-}
-
-// lookupPath returns the inode for the file/directory with the given
-// name (which may contain "/" separators), along with its parent
-// node. If no such file/directory exists, the returned node is nil.
-func (dn *dirnode) lookupPath(path string) (node inode) {
-       node = dn
-       for _, name := range strings.Split(path, "/") {
-               dn, ok := node.(*dirnode)
-               if !ok {
-                       return nil
-               }
-               if name == "." || name == "" {
-                       continue
-               }
-               if name == ".." {
-                       node = node.Parent()
-                       continue
+       _, err = node.Child(basename, func(child inode) (inode, error) {
+               switch child := child.(type) {
+               case nil:
+                       child, err = node.FS().newNode(basename, 0755, node.FileInfo().ModTime())
+                       if err != nil {
+                               return nil, err
+                       }
+                       child.SetParent(node, basename)
+                       fn = child.(*filenode)
+                       return child, nil
+               case *filenode:
+                       fn = child
+                       return child, nil
+               case *dirnode:
+                       return child, ErrIsDirectory
+               default:
+                       return child, ErrInvalidArgument
                }
-               dn.RLock()
-               node = dn.inodes[name]
-               dn.RUnlock()
-       }
+       })
        return
 }
 
-func (dn *dirnode) newDirnode(name string, perm os.FileMode, modTime time.Time) *dirnode {
-       child := &dirnode{
-               parent: dn,
-               client: dn.client,
-               kc:     dn.kc,
-               fileinfo: fileinfo{
-                       name:    name,
-                       mode:    os.ModeDir | perm,
-                       modTime: modTime,
-               },
-       }
-       if dn.inodes == nil {
-               dn.inodes = make(map[string]inode)
-       }
-       dn.inodes[name] = child
-       dn.fileinfo.size++
-       return child
-}
-
-func (dn *dirnode) newFilenode(name string, perm os.FileMode, modTime time.Time) *filenode {
-       child := &filenode{
-               parent: dn,
-               fileinfo: fileinfo{
-                       name:    name,
-                       mode:    perm,
-                       modTime: modTime,
-               },
-       }
-       if dn.inodes == nil {
-               dn.inodes = make(map[string]inode)
-       }
-       dn.inodes[name] = child
-       dn.fileinfo.size++
-       return child
-}
-
-// OpenFile is analogous to os.OpenFile().
-func (dn *dirnode) OpenFile(name string, flag int, perm os.FileMode) (*filehandle, error) {
-       if flag&os.O_SYNC != 0 {
-               return nil, ErrSyncNotSupported
-       }
-       dirname, name := path.Split(name)
-       dn, ok := dn.lookupPath(dirname).(*dirnode)
-       if !ok {
-               return nil, os.ErrNotExist
-       }
-       var readable, writable bool
-       switch flag & (os.O_RDWR | os.O_RDONLY | os.O_WRONLY) {
-       case os.O_RDWR:
-               readable = true
-               writable = true
-       case os.O_RDONLY:
-               readable = true
-       case os.O_WRONLY:
-               writable = true
-       default:
-               return nil, fmt.Errorf("invalid flags 0x%x", flag)
-       }
-       if !writable {
-               // A directory can be opened via "foo/", "foo/.", or
-               // "foo/..".
-               switch name {
-               case ".", "":
-                       return &filehandle{inode: dn}, nil
-               case "..":
-                       return &filehandle{inode: dn.Parent()}, nil
-               }
-       }
-       createMode := flag&os.O_CREATE != 0
-       if createMode {
-               dn.Lock()
-               defer dn.Unlock()
-       } else {
-               dn.RLock()
-               defer dn.RUnlock()
-       }
-       n, ok := dn.inodes[name]
-       if !ok {
-               if !createMode {
-                       return nil, os.ErrNotExist
-               }
-               if perm.IsDir() {
-                       n = dn.newDirnode(name, 0755, time.Now())
-               } else {
-                       n = dn.newFilenode(name, 0755, time.Now())
-               }
-       } else if flag&os.O_EXCL != 0 {
-               return nil, ErrFileExists
-       } else if flag&os.O_TRUNC != 0 {
-               if !writable {
-                       return nil, fmt.Errorf("invalid flag O_TRUNC in read-only mode")
-               } else if fn, ok := n.(*filenode); !ok {
-                       return nil, fmt.Errorf("invalid flag O_TRUNC when opening directory")
-               } else {
-                       fn.Truncate(0)
-               }
-       }
-       return &filehandle{
-               inode:    n,
-               append:   flag&os.O_APPEND != 0,
-               readable: readable,
-               writable: writable,
-       }, nil
-}
-
 type segment interface {
        io.ReaderAt
        Len() int
@@ -1347,7 +941,7 @@ func (me *memSegment) ReadAt(p []byte, off int64) (n int, err error) {
 }
 
 type storedSegment struct {
-       kc      keepClient
+       kc      fsBackend
        locator string
        size    int // size of stored block (also encoded in locator)
        offset  int // position of segment within the stored block
similarity index 99%
rename from sdk/go/arvados/collection_fs_test.go
rename to sdk/go/arvados/fs_collection_test.go
index 2604cefc4e55241a81f3d1a13f228f208c368609..d2f55d0e37d80502919f6385c2a0d457374a26c2 100644 (file)
@@ -474,6 +474,10 @@ func (s *CollectionFSSuite) TestMkdir(c *check.C) {
 }
 
 func (s *CollectionFSSuite) TestConcurrentWriters(c *check.C) {
+       if testing.Short() {
+               c.Skip("slow")
+       }
+
        maxBlockSize = 8
        defer func() { maxBlockSize = 2 << 26 }()
 
@@ -693,13 +697,13 @@ func (s *CollectionFSSuite) TestRename(c *check.C) {
                                err = fs.Rename(
                                        fmt.Sprintf("dir%d/file%d/patherror", i, j),
                                        fmt.Sprintf("dir%d/irrelevant", i))
-                               c.Check(err, check.ErrorMatches, `.*does not exist`)
+                               c.Check(err, check.ErrorMatches, `.*not a directory`)
 
                                // newname parent dir is a file
                                err = fs.Rename(
                                        fmt.Sprintf("dir%d/dir%d/file%d", i, j, j),
                                        fmt.Sprintf("dir%d/file%d/patherror", i, inner-j-1))
-                               c.Check(err, check.ErrorMatches, `.*does not exist`)
+                               c.Check(err, check.ErrorMatches, `.*not a directory`)
                        }(i, j)
                }
        }
@@ -1026,6 +1030,10 @@ var _ = check.Suite(&CollectionFSUnitSuite{})
 
 // expect ~2 seconds to load a manifest with 256K files
 func (s *CollectionFSUnitSuite) TestLargeManifest(c *check.C) {
+       if testing.Short() {
+               c.Skip("slow")
+       }
+
        const (
                dirCount  = 512
                fileCount = 512
diff --git a/sdk/go/arvados/fs_deferred.go b/sdk/go/arvados/fs_deferred.go
new file mode 100644 (file)
index 0000000..a84f64f
--- /dev/null
@@ -0,0 +1,103 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package arvados
+
+import (
+       "log"
+       "os"
+       "sync"
+       "time"
+)
+
+func deferredCollectionFS(fs FileSystem, parent inode, coll Collection) inode {
+       var modTime time.Time
+       if coll.ModifiedAt != nil {
+               modTime = *coll.ModifiedAt
+       } else {
+               modTime = time.Now()
+       }
+       placeholder := &treenode{
+               fs:     fs,
+               parent: parent,
+               inodes: nil,
+               fileinfo: fileinfo{
+                       name:    coll.Name,
+                       modTime: modTime,
+                       mode:    0755 | os.ModeDir,
+               },
+       }
+       return &deferrednode{wrapped: placeholder, create: func() inode {
+               err := fs.RequestAndDecode(&coll, "GET", "arvados/v1/collections/"+coll.UUID, nil, nil)
+               if err != nil {
+                       log.Printf("BUG: unhandled error: %s", err)
+                       return placeholder
+               }
+               cfs, err := coll.FileSystem(fs, fs)
+               if err != nil {
+                       log.Printf("BUG: unhandled error: %s", err)
+                       return placeholder
+               }
+               root := cfs.rootnode()
+               root.SetParent(parent, coll.Name)
+               return root
+       }}
+}
+
+// A deferrednode wraps an inode that's expensive to build. Initially,
+// it responds to basic directory functions by proxying to the given
+// placeholder. If a caller uses a read/write/lock operation,
+// deferrednode calls the create() func to create the real inode, and
+// proxies to the real inode from then on.
+//
+// In practice, this means a deferrednode's parent's directory listing
+// can be generated using only the placeholder, instead of waiting for
+// create().
+type deferrednode struct {
+       wrapped inode
+       create  func() inode
+       mtx     sync.Mutex
+       created bool
+}
+
+func (dn *deferrednode) realinode() inode {
+       dn.mtx.Lock()
+       defer dn.mtx.Unlock()
+       if !dn.created {
+               dn.wrapped = dn.create()
+               dn.created = true
+       }
+       return dn.wrapped
+}
+
+func (dn *deferrednode) currentinode() inode {
+       dn.mtx.Lock()
+       defer dn.mtx.Unlock()
+       return dn.wrapped
+}
+
+func (dn *deferrednode) Read(p []byte, pos filenodePtr) (int, filenodePtr, error) {
+       return dn.realinode().Read(p, pos)
+}
+
+func (dn *deferrednode) Write(p []byte, pos filenodePtr) (int, filenodePtr, error) {
+       return dn.realinode().Write(p, pos)
+}
+
+func (dn *deferrednode) Child(name string, replace func(inode) (inode, error)) (inode, error) {
+       return dn.realinode().Child(name, replace)
+}
+
+func (dn *deferrednode) Truncate(size int64) error       { return dn.realinode().Truncate(size) }
+func (dn *deferrednode) SetParent(p inode, name string)  { dn.realinode().SetParent(p, name) }
+func (dn *deferrednode) IsDir() bool                     { return dn.currentinode().IsDir() }
+func (dn *deferrednode) Readdir() ([]os.FileInfo, error) { return dn.realinode().Readdir() }
+func (dn *deferrednode) Size() int64                     { return dn.currentinode().Size() }
+func (dn *deferrednode) FileInfo() os.FileInfo           { return dn.currentinode().FileInfo() }
+func (dn *deferrednode) Lock()                           { dn.realinode().Lock() }
+func (dn *deferrednode) Unlock()                         { dn.realinode().Unlock() }
+func (dn *deferrednode) RLock()                          { dn.realinode().RLock() }
+func (dn *deferrednode) RUnlock()                        { dn.realinode().RUnlock() }
+func (dn *deferrednode) FS() FileSystem                  { return dn.currentinode().FS() }
+func (dn *deferrednode) Parent() inode                   { return dn.currentinode().Parent() }
diff --git a/sdk/go/arvados/fs_filehandle.go b/sdk/go/arvados/fs_filehandle.go
new file mode 100644 (file)
index 0000000..127bee8
--- /dev/null
@@ -0,0 +1,108 @@
+package arvados
+
+import (
+       "io"
+       "os"
+)
+
+type filehandle struct {
+       inode
+       ptr        filenodePtr
+       append     bool
+       readable   bool
+       writable   bool
+       unreaddirs []os.FileInfo
+}
+
+func (f *filehandle) Read(p []byte) (n int, err error) {
+       if !f.readable {
+               return 0, ErrWriteOnlyMode
+       }
+       f.inode.RLock()
+       defer f.inode.RUnlock()
+       n, f.ptr, err = f.inode.Read(p, f.ptr)
+       return
+}
+
+func (f *filehandle) Seek(off int64, whence int) (pos int64, err error) {
+       size := f.inode.Size()
+       ptr := f.ptr
+       switch whence {
+       case io.SeekStart:
+               ptr.off = off
+       case io.SeekCurrent:
+               ptr.off += off
+       case io.SeekEnd:
+               ptr.off = size + off
+       }
+       if ptr.off < 0 {
+               return f.ptr.off, ErrNegativeOffset
+       }
+       if ptr.off != f.ptr.off {
+               f.ptr = ptr
+               // force filenode to recompute f.ptr fields on next
+               // use
+               f.ptr.repacked = -1
+       }
+       return f.ptr.off, nil
+}
+
+func (f *filehandle) Truncate(size int64) error {
+       return f.inode.Truncate(size)
+}
+
+func (f *filehandle) Write(p []byte) (n int, err error) {
+       if !f.writable {
+               return 0, ErrReadOnlyFile
+       }
+       f.inode.Lock()
+       defer f.inode.Unlock()
+       if fn, ok := f.inode.(*filenode); ok && f.append {
+               f.ptr = filenodePtr{
+                       off:        fn.fileinfo.size,
+                       segmentIdx: len(fn.segments),
+                       segmentOff: 0,
+                       repacked:   fn.repacked,
+               }
+       }
+       n, f.ptr, err = f.inode.Write(p, f.ptr)
+       return
+}
+
+func (f *filehandle) Readdir(count int) ([]os.FileInfo, error) {
+       if !f.inode.IsDir() {
+               return nil, ErrInvalidOperation
+       }
+       if count <= 0 {
+               return f.inode.Readdir()
+       }
+       if f.unreaddirs == nil {
+               var err error
+               f.unreaddirs, err = f.inode.Readdir()
+               if err != nil {
+                       return nil, err
+               }
+       }
+       if len(f.unreaddirs) == 0 {
+               return nil, io.EOF
+       }
+       if count > len(f.unreaddirs) {
+               count = len(f.unreaddirs)
+       }
+       ret := f.unreaddirs[:count]
+       f.unreaddirs = f.unreaddirs[count:]
+       return ret, nil
+}
+
+func (f *filehandle) Stat() (os.FileInfo, error) {
+       return f.inode.FileInfo(), nil
+}
+
+func (f *filehandle) Close() error {
+       return nil
+}
+
+func (f *filehandle) Sync() error {
+       // Sync the containing filesystem.
+       return f.FS().Sync()
+}
diff --git a/sdk/go/arvados/fs_getternode.go b/sdk/go/arvados/fs_getternode.go
new file mode 100644 (file)
index 0000000..966fe9d
--- /dev/null
@@ -0,0 +1,66 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package arvados
+
+import (
+       "bytes"
+       "os"
+       "time"
+)
+
+// A getternode is a read-only character device that returns whatever
+// data is returned by the supplied function.
+type getternode struct {
+       Getter func() ([]byte, error)
+
+       treenode
+       data *bytes.Reader
+}
+
+func (*getternode) IsDir() bool {
+       return false
+}
+
+func (*getternode) Child(string, func(inode) (inode, error)) (inode, error) {
+       return nil, ErrInvalidArgument
+}
+
+func (gn *getternode) get() error {
+       if gn.data != nil {
+               return nil
+       }
+       data, err := gn.Getter()
+       if err != nil {
+               return err
+       }
+       gn.data = bytes.NewReader(data)
+       return nil
+}
+
+func (gn *getternode) Size() int64 {
+       return gn.FileInfo().Size()
+}
+
+func (gn *getternode) FileInfo() os.FileInfo {
+       gn.Lock()
+       defer gn.Unlock()
+       var size int64
+       if gn.get() == nil {
+               size = gn.data.Size()
+       }
+       return fileinfo{
+               modTime: time.Now(),
+               mode:    0444,
+               size:    size,
+       }
+}
+
+func (gn *getternode) Read(p []byte, ptr filenodePtr) (int, filenodePtr, error) {
+       if err := gn.get(); err != nil {
+               return 0, ptr, err
+       }
+       n, err := gn.data.ReadAt(p, ptr.off)
+       return n, filenodePtr{off: ptr.off + int64(n)}, err
+}
diff --git a/sdk/go/arvados/fs_project.go b/sdk/go/arvados/fs_project.go
new file mode 100644 (file)
index 0000000..7f34a42
--- /dev/null
@@ -0,0 +1,136 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package arvados
+
+import (
+       "os"
+       "sync"
+       "time"
+)
+
+type staleChecker struct {
+       mtx  sync.Mutex
+       last time.Time
+}
+
+func (sc *staleChecker) DoIfStale(fn func(), staleFunc func(time.Time) bool) {
+       sc.mtx.Lock()
+       defer sc.mtx.Unlock()
+       if !staleFunc(sc.last) {
+               return
+       }
+       sc.last = time.Now()
+       fn()
+}
+
+// projectnode exposes an Arvados project as a filesystem directory.
+type projectnode struct {
+       inode
+       staleChecker
+       uuid string
+       err  error
+}
+
+func (pn *projectnode) load() {
+       fs := pn.FS().(*customFileSystem)
+
+       if pn.uuid == "" {
+               var resp User
+               pn.err = fs.RequestAndDecode(&resp, "GET", "arvados/v1/users/current", nil, nil)
+               if pn.err != nil {
+                       return
+               }
+               pn.uuid = resp.UUID
+       }
+       filters := []Filter{{"owner_uuid", "=", pn.uuid}}
+       params := ResourceListParams{
+               Filters: filters,
+               Order:   "uuid",
+       }
+       for {
+               var resp CollectionList
+               pn.err = fs.RequestAndDecode(&resp, "GET", "arvados/v1/collections", nil, params)
+               if pn.err != nil {
+                       return
+               }
+               if len(resp.Items) == 0 {
+                       break
+               }
+               for _, i := range resp.Items {
+                       coll := i
+                       if coll.Name == "" {
+                               continue
+                       }
+                       pn.inode.Child(coll.Name, func(inode) (inode, error) {
+                               return deferredCollectionFS(fs, pn, coll), nil
+                       })
+               }
+               params.Filters = append(filters, Filter{"uuid", ">", resp.Items[len(resp.Items)-1].UUID})
+       }
+
+       filters = append(filters, Filter{"group_class", "=", "project"})
+       params.Filters = filters
+       for {
+               var resp GroupList
+               pn.err = fs.RequestAndDecode(&resp, "GET", "arvados/v1/groups", nil, params)
+               if pn.err != nil {
+                       return
+               }
+               if len(resp.Items) == 0 {
+                       break
+               }
+               for _, group := range resp.Items {
+                       if group.Name == "" || group.Name == "." || group.Name == ".." {
+                               continue
+                       }
+                       pn.inode.Child(group.Name, func(inode) (inode, error) {
+                               return fs.newProjectNode(pn, group.Name, group.UUID), nil
+                       })
+               }
+               params.Filters = append(filters, Filter{"uuid", ">", resp.Items[len(resp.Items)-1].UUID})
+       }
+       pn.err = nil
+}
+
+func (pn *projectnode) Readdir() ([]os.FileInfo, error) {
+       pn.staleChecker.DoIfStale(pn.load, pn.FS().(*customFileSystem).Stale)
+       if pn.err != nil {
+               return nil, pn.err
+       }
+       return pn.inode.Readdir()
+}
+
+func (pn *projectnode) Child(name string, replace func(inode) (inode, error)) (inode, error) {
+       pn.staleChecker.DoIfStale(pn.load, pn.FS().(*customFileSystem).Stale)
+       if pn.err != nil {
+               return nil, pn.err
+       }
+       if replace == nil {
+               // lookup
+               return pn.inode.Child(name, nil)
+       }
+       return pn.inode.Child(name, func(existing inode) (inode, error) {
+               if repl, err := replace(existing); err != nil {
+                       return existing, err
+               } else if repl == nil {
+                       if existing == nil {
+                               return nil, nil
+                       }
+                       // rmdir
+                       // (TODO)
+                       return existing, ErrInvalidArgument
+               } else if existing != nil {
+                       // clobber
+                       return existing, ErrInvalidArgument
+               } else if repl.FileInfo().IsDir() {
+                       // mkdir
+                       // TODO: repl.SetParent(pn, name), etc.
+                       return existing, ErrInvalidArgument
+               } else {
+                       // create file
+                       return existing, ErrInvalidArgument
+               }
+       })
+}
diff --git a/sdk/go/arvados/fs_project_test.go b/sdk/go/arvados/fs_project_test.go
new file mode 100644 (file)
index 0000000..69bae89
--- /dev/null
@@ -0,0 +1,143 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package arvados
+
+import (
+       "bytes"
+       "encoding/json"
+       "io"
+       "os"
+       "path/filepath"
+
+       "git.curoverse.com/arvados.git/sdk/go/arvadostest"
+       check "gopkg.in/check.v1"
+)
+
+type spiedRequest struct {
+       method string
+       path   string
+       params map[string]interface{}
+}
+
+type spyingClient struct {
+       *Client
+       calls []spiedRequest
+}
+
+func (sc *spyingClient) RequestAndDecode(dst interface{}, method, path string, body io.Reader, params interface{}) error {
+       var paramsCopy map[string]interface{}
+       var buf bytes.Buffer
+       json.NewEncoder(&buf).Encode(params)
+       json.NewDecoder(&buf).Decode(&paramsCopy)
+       sc.calls = append(sc.calls, spiedRequest{
+               method: method,
+               path:   path,
+               params: paramsCopy,
+       })
+       return sc.Client.RequestAndDecode(dst, method, path, body, params)
+}
+
+func (s *SiteFSSuite) TestCurrentUserHome(c *check.C) {
+       s.fs.MountProject("home", "")
+       s.testHomeProject(c, "/home")
+}
+
+func (s *SiteFSSuite) TestUsersDir(c *check.C) {
+       s.testHomeProject(c, "/users/active")
+}
+
+func (s *SiteFSSuite) testHomeProject(c *check.C, path string) {
+       f, err := s.fs.Open(path)
+       c.Assert(err, check.IsNil)
+       fis, err := f.Readdir(-1)
+       c.Check(len(fis), check.Not(check.Equals), 0)
+
+       ok := false
+       for _, fi := range fis {
+               c.Check(fi.Name(), check.Not(check.Equals), "")
+               if fi.Name() == "A Project" {
+                       ok = true
+               }
+       }
+       c.Check(ok, check.Equals, true)
+
+       f, err = s.fs.Open(path + "/A Project/..")
+       c.Assert(err, check.IsNil)
+       fi, err := f.Stat()
+       c.Check(err, check.IsNil)
+       c.Check(fi.IsDir(), check.Equals, true)
+       _, basename := filepath.Split(path)
+       c.Check(fi.Name(), check.Equals, basename)
+
+       f, err = s.fs.Open(path + "/A Project/A Subproject")
+       c.Check(err, check.IsNil)
+       fi, err = f.Stat()
+       c.Check(err, check.IsNil)
+       c.Check(fi.IsDir(), check.Equals, true)
+
+       for _, nx := range []string{
+               path + "/Unrestricted public data",
+               path + "/Unrestricted public data/does not exist",
+               path + "/A Project/does not exist",
+       } {
+               c.Log(nx)
+               f, err = s.fs.Open(nx)
+               c.Check(err, check.NotNil)
+               c.Check(os.IsNotExist(err), check.Equals, true)
+       }
+}
+
+func (s *SiteFSSuite) TestProjectUpdatedByOther(c *check.C) {
+       s.fs.MountProject("home", "")
+
+       project, err := s.fs.OpenFile("/home/A Project", 0, 0)
+       c.Check(err, check.IsNil)
+
+       _, err = s.fs.Open("/home/A Project/oob")
+       c.Check(err, check.NotNil)
+
+       oob := Collection{
+               Name:      "oob",
+               OwnerUUID: arvadostest.AProjectUUID,
+       }
+       err = s.client.RequestAndDecode(&oob, "POST", "arvados/v1/collections", s.client.UpdateBody(&oob), nil)
+       c.Assert(err, check.IsNil)
+       defer s.client.RequestAndDecode(nil, "DELETE", "arvados/v1/collections/"+oob.UUID, nil, nil)
+
+       err = project.Sync()
+       c.Check(err, check.IsNil)
+       f, err := s.fs.Open("/home/A Project/oob")
+       c.Assert(err, check.IsNil)
+       fi, err := f.Stat()
+       c.Check(fi.IsDir(), check.Equals, true)
+       f.Close()
+
+       wf, err := s.fs.OpenFile("/home/A Project/oob/test.txt", os.O_CREATE|os.O_RDWR, 0700)
+       c.Assert(err, check.IsNil)
+       _, err = wf.Write([]byte("hello oob\n"))
+       c.Check(err, check.IsNil)
+       err = wf.Close()
+       c.Check(err, check.IsNil)
+
+       // Delete test.txt behind s.fs's back by updating the
+       // collection record with the old (empty) ManifestText.
+       err = s.client.RequestAndDecode(nil, "PATCH", "arvados/v1/collections/"+oob.UUID, s.client.UpdateBody(&oob), nil)
+       c.Assert(err, check.IsNil)
+
+       err = project.Sync()
+       c.Check(err, check.IsNil)
+       _, err = s.fs.Open("/home/A Project/oob/test.txt")
+       c.Check(err, check.NotNil)
+       _, err = s.fs.Open("/home/A Project/oob")
+       c.Check(err, check.IsNil)
+
+       err = s.client.RequestAndDecode(nil, "DELETE", "arvados/v1/collections/"+oob.UUID, nil, nil)
+       c.Assert(err, check.IsNil)
+
+       err = project.Sync()
+       c.Check(err, check.IsNil)
+       _, err = s.fs.Open("/home/A Project/oob")
+       c.Check(err, check.NotNil)
+}
diff --git a/sdk/go/arvados/fs_site.go b/sdk/go/arvados/fs_site.go
new file mode 100644 (file)
index 0000000..e9c8387
--- /dev/null
@@ -0,0 +1,200 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package arvados
+
+import (
+       "os"
+       "sync"
+       "time"
+)
+
+type CustomFileSystem interface {
+       FileSystem
+       MountByID(mount string)
+       MountProject(mount, uuid string)
+       MountUsers(mount string)
+}
+
+type customFileSystem struct {
+       fileSystem
+       root *vdirnode
+
+       staleThreshold time.Time
+       staleLock      sync.Mutex
+}
+
+func (c *Client) CustomFileSystem(kc keepClient) CustomFileSystem {
+       root := &vdirnode{}
+       fs := &customFileSystem{
+               root: root,
+               fileSystem: fileSystem{
+                       fsBackend: keepBackend{apiClient: c, keepClient: kc},
+                       root:      root,
+               },
+       }
+       root.inode = &treenode{
+               fs:     fs,
+               parent: root,
+               fileinfo: fileinfo{
+                       name:    "/",
+                       mode:    os.ModeDir | 0755,
+                       modTime: time.Now(),
+               },
+               inodes: make(map[string]inode),
+       }
+       return fs
+}
+
+func (fs *customFileSystem) MountByID(mount string) {
+       fs.root.inode.Child(mount, func(inode) (inode, error) {
+               return &vdirnode{
+                       inode: &treenode{
+                               fs:     fs,
+                               parent: fs.root,
+                               inodes: make(map[string]inode),
+                               fileinfo: fileinfo{
+                                       name:    mount,
+                                       modTime: time.Now(),
+                                       mode:    0755 | os.ModeDir,
+                               },
+                       },
+                       create: fs.mountCollection,
+               }, nil
+       })
+}
+
+func (fs *customFileSystem) MountProject(mount, uuid string) {
+       fs.root.inode.Child(mount, func(inode) (inode, error) {
+               return fs.newProjectNode(fs.root, mount, uuid), nil
+       })
+}
+
+func (fs *customFileSystem) MountUsers(mount string) {
+       fs.root.inode.Child(mount, func(inode) (inode, error) {
+               return &usersnode{
+                       inode: &treenode{
+                               fs:     fs,
+                               parent: fs.root,
+                               inodes: make(map[string]inode),
+                               fileinfo: fileinfo{
+                                       name:    mount,
+                                       modTime: time.Now(),
+                                       mode:    0755 | os.ModeDir,
+                               },
+                       },
+               }, nil
+       })
+}
+
+// SiteFileSystem returns a FileSystem that maps collections and other
+// Arvados objects onto a filesystem layout.
+//
+// This is experimental: the filesystem layout is not stable, and
+// there are significant known bugs and shortcomings. For example,
+// writes are not persisted until Sync() is called.
+func (c *Client) SiteFileSystem(kc keepClient) CustomFileSystem {
+       fs := c.CustomFileSystem(kc)
+       fs.MountByID("by_id")
+       fs.MountUsers("users")
+       return fs
+}
+
+func (fs *customFileSystem) Sync() error {
+       fs.staleLock.Lock()
+       defer fs.staleLock.Unlock()
+       fs.staleThreshold = time.Now()
+       return nil
+}
+
+// Stale returns true if information obtained at time t should be
+// considered stale.
+func (fs *customFileSystem) Stale(t time.Time) bool {
+       fs.staleLock.Lock()
+       defer fs.staleLock.Unlock()
+       return !fs.staleThreshold.Before(t)
+}
+
+func (fs *customFileSystem) newNode(name string, perm os.FileMode, modTime time.Time) (node inode, err error) {
+       return nil, ErrInvalidOperation
+}
+
+func (fs *customFileSystem) mountCollection(parent inode, id string) inode {
+       var coll Collection
+       err := fs.RequestAndDecode(&coll, "GET", "arvados/v1/collections/"+id, nil, nil)
+       if err != nil {
+               return nil
+       }
+       cfs, err := coll.FileSystem(fs, fs)
+       if err != nil {
+               return nil
+       }
+       root := cfs.rootnode()
+       root.SetParent(parent, id)
+       return root
+}
+
+func (fs *customFileSystem) newProjectNode(root inode, name, uuid string) inode {
+       return &projectnode{
+               uuid: uuid,
+               inode: &treenode{
+                       fs:     fs,
+                       parent: root,
+                       inodes: make(map[string]inode),
+                       fileinfo: fileinfo{
+                               name:    name,
+                               modTime: time.Now(),
+                               mode:    0755 | os.ModeDir,
+                       },
+               },
+       }
+}
+
+func (fs *customFileSystem) newUserNode(root inode, name, uuid string) inode {
+       return &projectnode{
+               uuid: uuid,
+               inode: &treenode{
+                       fs:     fs,
+                       parent: root,
+                       inodes: make(map[string]inode),
+                       fileinfo: fileinfo{
+                               name:    name,
+                               modTime: time.Now(),
+                               mode:    0755 | os.ModeDir,
+                       },
+               },
+       }
+}
+
+// vdirnode wraps an inode by ignoring any requests to add/replace
+// children, and calling a create() func when a non-existing child is
+// looked up.
+//
+// create() can return either a new node, which will be added to the
+// treenode, or nil for ENOENT.
+type vdirnode struct {
+       inode
+       create func(parent inode, name string) inode
+}
+
+func (vn *vdirnode) Child(name string, replace func(inode) (inode, error)) (inode, error) {
+       return vn.inode.Child(name, func(existing inode) (inode, error) {
+               if existing == nil && vn.create != nil {
+                       existing = vn.create(vn, name)
+                       if existing != nil {
+                               existing.SetParent(vn, name)
+                               vn.inode.(*treenode).fileinfo.modTime = time.Now()
+                       }
+               }
+               if replace == nil {
+                       return existing, nil
+               } else if tryRepl, err := replace(existing); err != nil {
+                       return existing, err
+               } else if tryRepl != existing {
+                       return existing, ErrInvalidArgument
+               } else {
+                       return existing, nil
+               }
+       })
+}
diff --git a/sdk/go/arvados/fs_site_test.go b/sdk/go/arvados/fs_site_test.go
new file mode 100644 (file)
index 0000000..e35ae48
--- /dev/null
@@ -0,0 +1,83 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package arvados
+
+import (
+       "net/http"
+       "os"
+
+       "git.curoverse.com/arvados.git/sdk/go/arvadostest"
+       check "gopkg.in/check.v1"
+)
+
+var _ = check.Suite(&SiteFSSuite{})
+
+type SiteFSSuite struct {
+       client *Client
+       fs     CustomFileSystem
+       kc     keepClient
+}
+
+func (s *SiteFSSuite) SetUpTest(c *check.C) {
+       s.client = &Client{
+               APIHost:   os.Getenv("ARVADOS_API_HOST"),
+               AuthToken: arvadostest.ActiveToken,
+               Insecure:  true,
+       }
+       s.kc = &keepClientStub{
+               blocks: map[string][]byte{
+                       "3858f62230ac3c915f300c664312c63f": []byte("foobar"),
+               }}
+       s.fs = s.client.SiteFileSystem(s.kc)
+}
+
+func (s *SiteFSSuite) TestHttpFileSystemInterface(c *check.C) {
+       _, ok := s.fs.(http.FileSystem)
+       c.Check(ok, check.Equals, true)
+}
+
+func (s *SiteFSSuite) TestByIDEmpty(c *check.C) {
+       f, err := s.fs.Open("/by_id")
+       c.Assert(err, check.IsNil)
+       fis, err := f.Readdir(-1)
+       c.Check(len(fis), check.Equals, 0)
+}
+
+func (s *SiteFSSuite) TestByUUID(c *check.C) {
+       f, err := s.fs.Open("/by_id")
+       c.Assert(err, check.IsNil)
+       fis, err := f.Readdir(-1)
+       c.Check(err, check.IsNil)
+       c.Check(len(fis), check.Equals, 0)
+
+       err = s.fs.Mkdir("/by_id/"+arvadostest.FooCollection, 0755)
+       c.Check(err, check.Equals, os.ErrExist)
+
+       f, err = s.fs.Open("/by_id/" + arvadostest.NonexistentCollection)
+       c.Assert(err, check.Equals, os.ErrNotExist)
+
+       f, err = s.fs.Open("/by_id/" + arvadostest.FooCollection)
+       c.Assert(err, check.IsNil)
+       fis, err = f.Readdir(-1)
+       var names []string
+       for _, fi := range fis {
+               names = append(names, fi.Name())
+       }
+       c.Check(names, check.DeepEquals, []string{"foo"})
+
+       _, err = s.fs.OpenFile("/by_id/"+arvadostest.NonexistentCollection, os.O_RDWR|os.O_CREATE, 0755)
+       c.Check(err, check.Equals, ErrInvalidOperation)
+       err = s.fs.Rename("/by_id/"+arvadostest.FooCollection, "/by_id/beep")
+       c.Check(err, check.Equals, ErrInvalidArgument)
+       err = s.fs.Rename("/by_id/"+arvadostest.FooCollection+"/foo", "/by_id/beep")
+       c.Check(err, check.Equals, ErrInvalidArgument)
+       _, err = s.fs.Stat("/by_id/beep")
+       c.Check(err, check.Equals, os.ErrNotExist)
+       err = s.fs.Rename("/by_id/"+arvadostest.FooCollection+"/foo", "/by_id/"+arvadostest.FooCollection+"/bar")
+       c.Check(err, check.IsNil)
+
+       err = s.fs.Rename("/by_id", "/beep")
+       c.Check(err, check.Equals, ErrInvalidArgument)
+}
diff --git a/sdk/go/arvados/fs_users.go b/sdk/go/arvados/fs_users.go
new file mode 100644 (file)
index 0000000..ccfe2c5
--- /dev/null
@@ -0,0 +1,62 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package arvados
+
+import (
+       "os"
+)
+
+// usersnode is a virtual directory with an entry for each visible
+// Arvados username, each showing the respective user's "home
+// projects".
+type usersnode struct {
+       inode
+       staleChecker
+       err error
+}
+
+func (un *usersnode) load() {
+       fs := un.FS().(*customFileSystem)
+
+       params := ResourceListParams{
+               Order: "uuid",
+       }
+       for {
+               var resp UserList
+               un.err = fs.RequestAndDecode(&resp, "GET", "arvados/v1/users", nil, params)
+               if un.err != nil {
+                       return
+               }
+               if len(resp.Items) == 0 {
+                       break
+               }
+               for _, user := range resp.Items {
+                       if user.Username == "" {
+                               continue
+                       }
+                       un.inode.Child(user.Username, func(inode) (inode, error) {
+                               return fs.newProjectNode(un, user.Username, user.UUID), nil
+                       })
+               }
+               params.Filters = []Filter{{"uuid", ">", resp.Items[len(resp.Items)-1].UUID}}
+       }
+       un.err = nil
+}
+
+func (un *usersnode) Readdir() ([]os.FileInfo, error) {
+       un.staleChecker.DoIfStale(un.load, un.FS().(*customFileSystem).Stale)
+       if un.err != nil {
+               return nil, un.err
+       }
+       return un.inode.Readdir()
+}
+
+func (un *usersnode) Child(name string, _ func(inode) (inode, error)) (inode, error) {
+       un.staleChecker.DoIfStale(un.load, un.FS().(*customFileSystem).Stale)
+       if un.err != nil {
+               return nil, un.err
+       }
+       return un.inode.Child(name, nil)
+}
index d057c09b227e9f375d2b3d04e95d9327044c4f33..5fccfb3aa21d5cd348b2ccf309639cd9789ac881 100644 (file)
@@ -25,6 +25,9 @@ const (
        FooPdh                  = "1f4b0bc7583c2a7f9102c395f4ffc5e3+45"
        HelloWorldPdh           = "55713e6a34081eb03609e7ad5fcad129+62"
 
+       AProjectUUID    = "zzzzz-j7d0g-v955i6s2oi1cbso"
+       ASubprojectUUID = "zzzzz-j7d0g-axqo7eu9pwvna1x"
+
        FooAndBarFilesInDirUUID = "zzzzz-4zz18-foonbarfilesdir"
        FooAndBarFilesInDirPDH  = "6bbac24198d09a93975f60098caf0bdf+62"
 
index dc7e62f3e340741bec37e1d72bbb99c7ccf797d4..587f4db280f89d2212775a0791dc105b54c2838a 100644 (file)
@@ -159,7 +159,7 @@ class Arvados::V1::UsersController < ApplicationController
     return super if @read_users.any?(&:is_admin)
     if params[:uuid] != current_user.andand.uuid
       # Non-admin index/show returns very basic information about readable users.
-      safe_attrs = ["uuid", "is_active", "email", "first_name", "last_name"]
+      safe_attrs = ["uuid", "is_active", "email", "first_name", "last_name", "username"]
       if @select
         @select = @select & safe_attrs
       else
index a50648617fd59aea8fb1bdb39c7066b3b4e60974..39c905a8b2f751b8b4af8bc175c15270f94b3f13 100644 (file)
@@ -817,7 +817,7 @@ class Arvados::V1::UsersControllerTest < ActionController::TestCase
 
 
   NON_ADMIN_USER_DATA = ["uuid", "kind", "is_active", "email", "first_name",
-                         "last_name"].sort
+                         "last_name", "username"].sort
 
   def check_non_admin_index
     assert_response :success
index eb323674b9013daa80b7ee7bc1d472dcdd21cf01..843a8e89d3a1f13a08d0c517902621df14fb0eac 100644 (file)
@@ -6,11 +6,13 @@ package main
 
 import (
        "bytes"
+       "fmt"
        "io"
        "io/ioutil"
        "net/url"
        "os"
        "os/exec"
+       "path/filepath"
        "strings"
        "time"
 
@@ -19,34 +21,64 @@ import (
        check "gopkg.in/check.v1"
 )
 
-func (s *IntegrationSuite) TestWebdavWithCadaver(c *check.C) {
+func (s *IntegrationSuite) TestCadaverHTTPAuth(c *check.C) {
+       s.testCadaver(c, arvadostest.ActiveToken, func(newCollection arvados.Collection) (string, string, string) {
+               r := "/c=" + arvadostest.FooAndBarFilesInDirUUID + "/"
+               w := "/c=" + newCollection.UUID + "/"
+               pdh := "/c=" + strings.Replace(arvadostest.FooAndBarFilesInDirPDH, "+", "-", -1) + "/"
+               return r, w, pdh
+       }, nil)
+}
+
+func (s *IntegrationSuite) TestCadaverPathAuth(c *check.C) {
+       s.testCadaver(c, "", func(newCollection arvados.Collection) (string, string, string) {
+               r := "/c=" + arvadostest.FooAndBarFilesInDirUUID + "/t=" + arvadostest.ActiveToken + "/"
+               w := "/c=" + newCollection.UUID + "/t=" + arvadostest.ActiveToken + "/"
+               pdh := "/c=" + strings.Replace(arvadostest.FooAndBarFilesInDirPDH, "+", "-", -1) + "/t=" + arvadostest.ActiveToken + "/"
+               return r, w, pdh
+       }, nil)
+}
+
+func (s *IntegrationSuite) TestCadaverUserProject(c *check.C) {
+       rpath := "/users/active/foo_file_in_dir/"
+       s.testCadaver(c, arvadostest.ActiveToken, func(newCollection arvados.Collection) (string, string, string) {
+               wpath := "/users/active/" + newCollection.Name
+               pdh := "/c=" + strings.Replace(arvadostest.FooAndBarFilesInDirPDH, "+", "-", -1) + "/"
+               return rpath, wpath, pdh
+       }, func(path string) bool {
+               // Skip tests that rely on writes, because /users/
+               // tree is read-only.
+               return !strings.HasPrefix(path, rpath) || strings.HasPrefix(path, rpath+"_/")
+       })
+}
+
+func (s *IntegrationSuite) testCadaver(c *check.C, password string, pathFunc func(arvados.Collection) (string, string, string), skip func(string) bool) {
        testdata := []byte("the human tragedy consists in the necessity of living with the consequences of actions performed under the pressure of compulsions we do not understand")
 
-       localfile, err := ioutil.TempFile("", "localfile")
+       tempdir, err := ioutil.TempDir("", "keep-web-test-")
+       c.Assert(err, check.IsNil)
+       defer os.RemoveAll(tempdir)
+
+       localfile, err := ioutil.TempFile(tempdir, "localfile")
        c.Assert(err, check.IsNil)
-       defer os.Remove(localfile.Name())
        localfile.Write(testdata)
 
-       emptyfile, err := ioutil.TempFile("", "emptyfile")
+       emptyfile, err := ioutil.TempFile(tempdir, "emptyfile")
        c.Assert(err, check.IsNil)
-       defer os.Remove(emptyfile.Name())
 
-       checkfile, err := ioutil.TempFile("", "checkfile")
+       checkfile, err := ioutil.TempFile(tempdir, "checkfile")
        c.Assert(err, check.IsNil)
-       defer os.Remove(checkfile.Name())
 
        var newCollection arvados.Collection
        arv := arvados.NewClientFromEnv()
        arv.AuthToken = arvadostest.ActiveToken
        err = arv.RequestAndDecode(&newCollection, "POST", "/arvados/v1/collections", bytes.NewBufferString(url.Values{"collection": {"{}"}}.Encode()), nil)
        c.Assert(err, check.IsNil)
-       writePath := "/c=" + newCollection.UUID + "/t=" + arv.AuthToken + "/"
 
-       pdhPath := "/c=" + strings.Replace(arvadostest.FooAndBarFilesInDirPDH, "+", "-", -1) + "/t=" + arv.AuthToken + "/"
+       readPath, writePath, pdhPath := pathFunc(newCollection)
 
        matchToday := time.Now().Format("Jan +2")
 
-       readPath := "/c=" + arvadostest.FooAndBarFilesInDirUUID + "/t=" + arvadostest.ActiveToken + "/"
        type testcase struct {
                path  string
                cmd   string
@@ -211,10 +243,30 @@ func (s *IntegrationSuite) TestWebdavWithCadaver(c *check.C) {
                },
        } {
                c.Logf("%s %+v", "http://"+s.testServer.Addr, trial)
+               if skip != nil && skip(trial.path) {
+                       c.Log("(skip)")
+                       continue
+               }
 
                os.Remove(checkfile.Name())
 
                cmd := exec.Command("cadaver", "http://"+s.testServer.Addr+trial.path)
+               if password != "" {
+                       // cadaver won't try username/password
+                       // authentication unless the server responds
+                       // 401 to an unauthenticated request, which it
+                       // only does in AttachmentOnlyHost,
+                       // TrustAllContent, and per-collection vhost
+                       // cases.
+                       s.testServer.Config.AttachmentOnlyHost = s.testServer.Addr
+
+                       cmd.Env = append(os.Environ(), "HOME="+tempdir)
+                       f, err := os.OpenFile(filepath.Join(tempdir, ".netrc"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
+                       c.Assert(err, check.IsNil)
+                       _, err = fmt.Fprintf(f, "default login none password %s\n", password)
+                       c.Assert(err, check.IsNil)
+                       c.Assert(f.Close(), check.IsNil)
+               }
                cmd.Stdin = bytes.NewBufferString(trial.cmd)
                stdout, err := cmd.StdoutPipe()
                c.Assert(err, check.Equals, nil)
index 19a2040b4a5735551c0f7bf8a610c1fb109399b9..00af0f4eab86d3614170ef1e9f3af087ac333483 100644 (file)
@@ -226,21 +226,15 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                w.Header().Set("Access-Control-Expose-Headers", "Content-Range")
        }
 
-       arv := h.clientPool.Get()
-       if arv == nil {
-               statusCode, statusText = http.StatusInternalServerError, "Pool failed: "+h.clientPool.Err().Error()
-               return
-       }
-       defer h.clientPool.Put(arv)
-
        pathParts := strings.Split(r.URL.Path[1:], "/")
 
        var stripParts int
-       var targetID string
+       var collectionID string
        var tokens []string
        var reqTokens []string
        var pathToken bool
        var attachment bool
+       var useSiteFS bool
        credentialsOK := h.Config.TrustAllContent
 
        if r.Host != "" && r.Host == h.Config.AttachmentOnlyHost {
@@ -250,36 +244,43 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                attachment = true
        }
 
-       if targetID = parseCollectionIDFromDNSName(r.Host); targetID != "" {
+       if collectionID = parseCollectionIDFromDNSName(r.Host); collectionID != "" {
                // http://ID.collections.example/PATH...
                credentialsOK = true
        } else if r.URL.Path == "/status.json" {
                h.serveStatus(w, r)
                return
+       } else if len(pathParts) >= 1 && pathParts[0] == "users" {
+               useSiteFS = true
        } else if len(pathParts) >= 1 && strings.HasPrefix(pathParts[0], "c=") {
                // /c=ID[/PATH...]
-               targetID = parseCollectionIDFromURL(pathParts[0][2:])
+               collectionID = parseCollectionIDFromURL(pathParts[0][2:])
                stripParts = 1
        } else if len(pathParts) >= 2 && pathParts[0] == "collections" {
                if len(pathParts) >= 4 && pathParts[1] == "download" {
                        // /collections/download/ID/TOKEN/PATH...
-                       targetID = parseCollectionIDFromURL(pathParts[2])
+                       collectionID = parseCollectionIDFromURL(pathParts[2])
                        tokens = []string{pathParts[3]}
                        stripParts = 4
                        pathToken = true
                } else {
                        // /collections/ID/PATH...
-                       targetID = parseCollectionIDFromURL(pathParts[1])
+                       collectionID = parseCollectionIDFromURL(pathParts[1])
                        tokens = h.Config.AnonymousTokens
                        stripParts = 2
                }
        }
 
-       if targetID == "" {
+       if collectionID == "" && !useSiteFS {
                statusCode = http.StatusNotFound
                return
        }
 
+       forceReload := false
+       if cc := r.Header.Get("Cache-Control"); strings.Contains(cc, "no-cache") || strings.Contains(cc, "must-revalidate") {
+               forceReload = true
+       }
+
        formToken := r.FormValue("api_token")
        if formToken != "" && r.Header.Get("Origin") != "" && attachment && r.URL.Query().Get("api_token") == "" {
                // The client provided an explicit token in the POST
@@ -327,6 +328,11 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                tokens = append(reqTokens, h.Config.AnonymousTokens...)
        }
 
+       if useSiteFS {
+               h.serveSiteFS(w, r, tokens)
+               return
+       }
+
        if len(targetPath) > 0 && targetPath[0] == "_" {
                // If a collection has a directory called "t=foo" or
                // "_", it can be served at
@@ -338,16 +344,18 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                stripParts++
        }
 
-       forceReload := false
-       if cc := r.Header.Get("Cache-Control"); strings.Contains(cc, "no-cache") || strings.Contains(cc, "must-revalidate") {
-               forceReload = true
+       arv := h.clientPool.Get()
+       if arv == nil {
+               statusCode, statusText = http.StatusInternalServerError, "Pool failed: "+h.clientPool.Err().Error()
+               return
        }
+       defer h.clientPool.Put(arv)
 
        var collection *arvados.Collection
        tokenResult := make(map[string]int)
        for _, arv.ApiToken = range tokens {
                var err error
-               collection, err = h.Config.Cache.Get(arv, targetID, forceReload)
+               collection, err = h.Config.Cache.Get(arv, collectionID, forceReload)
                if err == nil {
                        // Success
                        break
@@ -414,14 +422,16 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                AuthToken: arv.ApiToken,
                Insecure:  arv.ApiInsecure,
        }
+
        fs, err := collection.FileSystem(client, kc)
        if err != nil {
                statusCode, statusText = http.StatusInternalServerError, err.Error()
                return
        }
 
-       targetIsPDH := arvadosclient.PDHMatch(targetID)
-       if targetIsPDH && writeMethod[r.Method] {
+       writefs, writeOK := fs.(arvados.CollectionFileSystem)
+       targetIsPDH := arvadosclient.PDHMatch(collectionID)
+       if (targetIsPDH || !writeOK) && writeMethod[r.Method] {
                statusCode, statusText = http.StatusMethodNotAllowed, errReadOnly.Error()
                return
        }
@@ -435,7 +445,7 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                        w = &updateOnSuccess{
                                ResponseWriter: w,
                                update: func() error {
-                                       return h.Config.Cache.Update(client, *collection, fs)
+                                       return h.Config.Cache.Update(client, *collection, writefs)
                                }}
                }
                h := webdav.Handler{
@@ -473,7 +483,7 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
                // "dirname/fnm".
                h.seeOtherWithCookie(w, r, r.URL.Path+"/", credentialsOK)
        } else if stat.IsDir() {
-               h.serveDirectory(w, r, collection.Name, fs, openPath, stripParts)
+               h.serveDirectory(w, r, collection.Name, fs, openPath, true)
        } else {
                http.ServeContent(w, r, basename, stat.ModTime(), f)
                if r.Header.Get("Range") == "" && int64(w.WroteBodyBytes()) != stat.Size() {
@@ -489,10 +499,69 @@ func (h *handler) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
        }
 }
 
+func (h *handler) serveSiteFS(w http.ResponseWriter, r *http.Request, tokens []string) {
+       if len(tokens) == 0 {
+               w.Header().Add("WWW-Authenticate", "Basic realm=\"collections\"")
+               http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
+               return
+       }
+       if writeMethod[r.Method] {
+               http.Error(w, errReadOnly.Error(), http.StatusMethodNotAllowed)
+               return
+       }
+       arv := h.clientPool.Get()
+       if arv == nil {
+               http.Error(w, "Pool failed: "+h.clientPool.Err().Error(), http.StatusInternalServerError)
+               return
+       }
+       defer h.clientPool.Put(arv)
+       arv.ApiToken = tokens[0]
+
+       kc, err := keepclient.MakeKeepClient(arv)
+       if err != nil {
+               http.Error(w, err.Error(), http.StatusInternalServerError)
+               return
+       }
+       client := &arvados.Client{
+               APIHost:   arv.ApiServer,
+               AuthToken: arv.ApiToken,
+               Insecure:  arv.ApiInsecure,
+       }
+       fs := client.SiteFileSystem(kc)
+       if f, err := fs.Open(r.URL.Path); os.IsNotExist(err) {
+               http.Error(w, err.Error(), http.StatusNotFound)
+               return
+       } else if err != nil {
+               http.Error(w, err.Error(), http.StatusInternalServerError)
+               return
+       } else if fi, err := f.Stat(); err == nil && fi.IsDir() && r.Method == "GET" {
+
+               h.serveDirectory(w, r, fi.Name(), fs, r.URL.Path, false)
+               return
+       } else {
+               f.Close()
+       }
+       wh := webdav.Handler{
+               Prefix: "/",
+               FileSystem: &webdavFS{
+                       collfs:        fs,
+                       writing:       writeMethod[r.Method],
+                       alwaysReadEOF: r.Method == "PROPFIND",
+               },
+               LockSystem: h.webdavLS,
+               Logger: func(_ *http.Request, err error) {
+                       if err != nil {
+                               log.Printf("error from webdav handler: %q", err)
+                       }
+               },
+       }
+       wh.ServeHTTP(w, r)
+}
+
 var dirListingTemplate = `<!DOCTYPE HTML>
 <HTML><HEAD>
   <META name="robots" content="NOINDEX">
-  <TITLE>{{ .Collection.Name }}</TITLE>
+  <TITLE>{{ .CollectionName }}</TITLE>
   <STYLE type="text/css">
     body {
       margin: 1.5em;
@@ -516,19 +585,26 @@ var dirListingTemplate = `<!DOCTYPE HTML>
   </STYLE>
 </HEAD>
 <BODY>
+
 <H1>{{ .CollectionName }}</H1>
 
 <P>This collection of data files is being shared with you through
 Arvados.  You can download individual files listed below.  To download
-the entire collection with wget, try:</P>
+the entire directory tree with wget, try:</P>
 
-<PRE>$ wget --mirror --no-parent --no-host --cut-dirs={{ .StripParts }} https://{{ .Request.Host }}{{ .Request.URL }}</PRE>
+<PRE>$ wget --mirror --no-parent --no-host --cut-dirs={{ .StripParts }} https://{{ .Request.Host }}{{ .Request.URL.Path }}</PRE>
 
 <H2>File Listing</H2>
 
 {{if .Files}}
 <UL>
-{{range .Files}}  <LI>{{.Size | printf "%15d  " | nbsp}}<A href="{{.Name}}">{{.Name}}</A></LI>{{end}}
+{{range .Files}}
+{{if .IsDir }}
+  <LI>{{" " | printf "%15s  " | nbsp}}<A href="{{.Name}}/">{{.Name}}/</A></LI>
+{{else}}
+  <LI>{{.Size | printf "%15d  " | nbsp}}<A href="{{.Name}}">{{.Name}}</A></LI>
+{{end}}
+{{end}}
 </UL>
 {{else}}
 <P>(No files; this collection is empty.)</P>
@@ -548,11 +624,12 @@ the entire collection with wget, try:</P>
 `
 
 type fileListEnt struct {
-       Name string
-       Size int64
+       Name  string
+       Size  int64
+       IsDir bool
 }
 
-func (h *handler) serveDirectory(w http.ResponseWriter, r *http.Request, collectionName string, fs http.FileSystem, base string, stripParts int) {
+func (h *handler) serveDirectory(w http.ResponseWriter, r *http.Request, collectionName string, fs http.FileSystem, base string, recurse bool) {
        var files []fileListEnt
        var walk func(string) error
        if !strings.HasSuffix(base, "/") {
@@ -572,15 +649,16 @@ func (h *handler) serveDirectory(w http.ResponseWriter, r *http.Request, collect
                        return err
                }
                for _, ent := range ents {
-                       if ent.IsDir() {
+                       if recurse && ent.IsDir() {
                                err = walk(path + ent.Name() + "/")
                                if err != nil {
                                        return err
                                }
                        } else {
                                files = append(files, fileListEnt{
-                                       Name: path + ent.Name(),
-                                       Size: ent.Size(),
+                                       Name:  path + ent.Name(),
+                                       Size:  ent.Size(),
+                                       IsDir: ent.IsDir(),
                                })
                        }
                }
@@ -609,7 +687,7 @@ func (h *handler) serveDirectory(w http.ResponseWriter, r *http.Request, collect
                "CollectionName": collectionName,
                "Files":          files,
                "Request":        r,
-               "StripParts":     stripParts,
+               "StripParts":     strings.Count(strings.TrimRight(r.URL.Path, "/"), "/"),
        })
 }
 
index 21e47c8dc7c3e0a64f8d320e24b5cd9041fe7117..7fed6fbd628f42b76210bd44f28608b56b7e9c6b 100644 (file)
@@ -508,7 +508,7 @@ func (s *IntegrationSuite) TestDirectoryListing(c *check.C) {
                        uri:     strings.Replace(arvadostest.FooAndBarFilesInDirPDH, "+", "-", -1) + ".example.com/dir1/",
                        header:  authHeader,
                        expect:  []string{"foo", "bar"},
-                       cutDirs: 0,
+                       cutDirs: 1,
                },
                {
                        uri:     "download.example.com/collections/" + arvadostest.FooAndBarFilesInDirUUID + "/",
@@ -516,6 +516,30 @@ func (s *IntegrationSuite) TestDirectoryListing(c *check.C) {
                        expect:  []string{"dir1/foo", "dir1/bar"},
                        cutDirs: 2,
                },
+               {
+                       uri:     "download.example.com/users/active/foo_file_in_dir/",
+                       header:  authHeader,
+                       expect:  []string{"dir1/"},
+                       cutDirs: 3,
+               },
+               {
+                       uri:     "download.example.com/users/active/foo_file_in_dir/dir1/",
+                       header:  authHeader,
+                       expect:  []string{"bar"},
+                       cutDirs: 4,
+               },
+               {
+                       uri:     "download.example.com/users/",
+                       header:  authHeader,
+                       expect:  []string{"active/"},
+                       cutDirs: 1,
+               },
+               {
+                       uri:     "download.example.com/users/active/",
+                       header:  authHeader,
+                       expect:  []string{"foo_file_in_dir/"},
+                       cutDirs: 2,
+               },
                {
                        uri:     "collections.example.com/collections/download/" + arvadostest.FooAndBarFilesInDirUUID + "/" + arvadostest.ActiveToken + "/",
                        header:  nil,
@@ -544,19 +568,19 @@ func (s *IntegrationSuite) TestDirectoryListing(c *check.C) {
                        uri:     "download.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID + "/dir1/",
                        header:  authHeader,
                        expect:  []string{"foo", "bar"},
-                       cutDirs: 1,
+                       cutDirs: 2,
                },
                {
                        uri:     "download.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID + "/_/dir1/",
                        header:  authHeader,
                        expect:  []string{"foo", "bar"},
-                       cutDirs: 2,
+                       cutDirs: 3,
                },
                {
                        uri:     arvadostest.FooAndBarFilesInDirUUID + ".example.com/dir1?api_token=" + arvadostest.ActiveToken,
                        header:  authHeader,
                        expect:  []string{"foo", "bar"},
-                       cutDirs: 0,
+                       cutDirs: 1,
                },
                {
                        uri:    "collections.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID + "/theperthcountyconspiracydoesnotexist/",
index 432c6af6d89847068cfbc154a413ade33c5585dc..5b23c9c5fa9f10bffec55d48e6950fd0ac76d639 100644 (file)
@@ -36,7 +36,7 @@ var (
 // existence automatically so sequences like "mkcol foo; put foo/bar"
 // work as expected.
 type webdavFS struct {
-       collfs  arvados.CollectionFileSystem
+       collfs  arvados.FileSystem
        writing bool
        // webdav PROPFIND reads the first few bytes of each file
        // whose filename extension isn't recognized, which is
@@ -47,6 +47,9 @@ type webdavFS struct {
 }
 
 func (fs *webdavFS) makeparents(name string) {
+       if !fs.writing {
+               return
+       }
        dir, _ := path.Split(name)
        if dir == "" || dir == "/" {
                return
@@ -66,7 +69,7 @@ func (fs *webdavFS) Mkdir(ctx context.Context, name string, perm os.FileMode) er
 }
 
 func (fs *webdavFS) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (f webdav.File, err error) {
-       writing := flag&(os.O_WRONLY|os.O_RDWR) != 0
+       writing := flag&(os.O_WRONLY|os.O_RDWR|os.O_TRUNC) != 0
        if writing {
                fs.makeparents(name)
        }
@@ -75,8 +78,13 @@ func (fs *webdavFS) OpenFile(ctx context.Context, name string, flag int, perm os
                // webdav module returns 404 on all OpenFile errors,
                // but returns 405 Method Not Allowed if OpenFile()
                // succeeds but Write() or Close() fails. We'd rather
-               // have 405.
-               f = writeFailer{File: f, err: errReadOnly}
+               // have 405. writeFailer ensures Close() fails if the
+               // file is opened for writing *or* Write() is called.
+               var err error
+               if writing {
+                       err = errReadOnly
+               }
+               f = writeFailer{File: f, err: err}
        }
        if fs.alwaysReadEOF {
                f = readEOF{File: f}
@@ -109,10 +117,15 @@ type writeFailer struct {
 }
 
 func (wf writeFailer) Write([]byte) (int, error) {
+       wf.err = errReadOnly
        return 0, wf.err
 }
 
 func (wf writeFailer) Close() error {
+       err := wf.File.Close()
+       if err != nil {
+               wf.err = err
+       }
        return wf.err
 }