diff --git a/nodefs/api.go b/nodefs/api.go
new file mode 100644
index 0000000000000000000000000000000000000000..66d7347cca9bcbbb450af715847d023104e55afc
--- /dev/null
+++ b/nodefs/api.go
@@ -0,0 +1,169 @@
+// Copyright 2019 the Go-FUSE Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package nodefs provides infrastructure to build tree-organized filesystems.
+//
+// A tree-organized filesystem is similar to UNIX or Plan 9 filesystem: it
+// consists of nodes with each node being either a file or a directory. Files
+// are located at tree leafs. A directory node can have other nodes as its
+// children and refer to each child by name unique through the directory.
+// There can be several paths leading from tree root to a particular node,
+// known as hard-linking, for example
+//
+//	    root
+//	    /  \
+//	  dir1 dir2
+//	    \  /
+//	    file
+//
+// A /-separated string path describes location of a node in the tree. For example
+//
+//	/dir1/file
+//
+// describes path root → dir1 → file.
+//
+// Each node is associated with integer ID uniquely identifying the node
+// throughout filesystem. The tree-level structure of any filesystem is
+// expressed through index-nodes (also known as "inode", see Inode) which
+// describe parent/child relation in between nodes and node-ID association.
+//
+// A particular filesystem should provide nodes with filesystem operations
+// implemented as defined by Node interface. When filesystem is mounted, its
+// root Node is associated with root of the tree, and the tree is further build
+// lazily when nodefs infrastructure needs to lookup children of nodes to
+// process client requests. For every new node, the filesystem infrastructure
+// automatically builds new index node and links it in the filesystem tree.
+// InodeOf can be used to get particular Inode associated with a Node.
+//
+// XXX ^^^ inodes cleaned on cache clean (FORGET).
+//
+// XXX describe how to mount.
+//
+// XXX node example with Lookup.
+//
+// XXX describe how to pre-add nodes to tree.
+//
+package nodefs
+
+import (
+	"context"
+	"time"
+
+	"github.com/hanwen/go-fuse/fuse"
+)
+
+// InodeOf returns index-node associated with filesystem node.
+//
+// The identity of the Inode does not change over the lifetime of
+// the node object.
+func InodeOf(node Node) *Inode {
+	return node.inode()
+}
+
+/*
+NOSUBMIT: how to structure?
+
+- one interface per method?
+- one interface for files (getattr, read/write), one for dirs (lookup, opendir), one shared?
+- one giant interface?
+- use raw types as args rather than mimicking Golang signatures?
+
+Every Node implementation must directly or indirectly embed DefaultNode.
+*/
+type Node interface {
+	// setInode and inode are used by nodefs internally to link Inode to a Node.
+	//
+	// When a new Node instance is created, e.g. on Lookup, it has nil Inode.
+	// Nodefs infrastructure will notice this and associate created Node with new Inode.
+	//
+	// See InodeOf for public API to retrieve an inode from Node.
+	inode() *Inode
+	setInode(*Inode) (set bool)
+
+	// Lookup should find a direct child of the node by child name.
+	//
+	// VFS makes sure to call Lookup only once for particular (node, name)
+	// pair.
+	Lookup(ctx context.Context, name string, out *fuse.EntryOut) (*Inode, fuse.Status)
+
+	Open(ctx context.Context, flags uint32) (fh File, fuseFlags uint32, code fuse.Status)
+
+	Create(ctx context.Context, name string, flags uint32, mode uint32) (node *Inode, fh File, fuseFlags uint32, code fuse.Status)
+
+	Read(ctx context.Context, f File, dest []byte, off int64) (fuse.ReadResult, fuse.Status)
+
+	Write(ctx context.Context, f File, data []byte, off int64) (written uint32, code fuse.Status)
+
+	// File locking
+	GetLk(ctx context.Context, f File, owner uint64, lk *fuse.FileLock, flags uint32, out *fuse.FileLock) (code fuse.Status)
+	SetLk(ctx context.Context, f File, owner uint64, lk *fuse.FileLock, flags uint32) (code fuse.Status)
+	SetLkw(ctx context.Context, f File, owner uint64, lk *fuse.FileLock, flags uint32) (code fuse.Status)
+
+	// Flush is called for close() call on a file descriptor. In
+	// case of duplicated descriptor, it may be called more than
+	// once for a file.
+	Flush(ctx context.Context, f File) fuse.Status
+
+	// This is called to before the file handle is forgotten. This
+	// method has no return value, so nothing can synchronizes on
+	// the call. Any cleanup that requires specific synchronization or
+	// could fail with I/O errors should happen in Flush instead.
+	Release(ctx context.Context, f File)
+
+	// The methods below may be called on closed files, due to
+	// concurrency.  In that case, you should return EBADF.
+	GetAttr(ctx context.Context, f File, out *fuse.Attr) fuse.Status
+
+	/*
+		NOSUBMIT - fold into a setattr method, or expand methods?
+
+		Decoding SetAttr is a bit of a PITA, but if we use fuse
+		types as args, we can't take apart SetAttr for the caller
+	*/
+
+	Truncate(ctx context.Context, f File, size uint64) fuse.Status
+	Chown(ctx context.Context, f File, uid uint32, gid uint32) fuse.Status
+	Chmod(ctx context.Context, f File, perms uint32) fuse.Status
+	Utimens(ctx context.Context, f File, atime *time.Time, mtime *time.Time) fuse.Status
+	Allocate(ctx context.Context, f File, off uint64, size uint64, mode uint32) (code fuse.Status)
+}
+
+type File interface {
+	Read(ctx context.Context, dest []byte, off int64) (fuse.ReadResult, fuse.Status)
+	Write(ctx context.Context, data []byte, off int64) (written uint32, code fuse.Status)
+
+	// File locking
+	GetLk(ctx context.Context, owner uint64, lk *fuse.FileLock, flags uint32, out *fuse.FileLock) (code fuse.Status)
+	SetLk(ctx context.Context, owner uint64, lk *fuse.FileLock, flags uint32) (code fuse.Status)
+	SetLkw(ctx context.Context, owner uint64, lk *fuse.FileLock, flags uint32) (code fuse.Status)
+
+	// Flush is called for close() call on a file descriptor. In
+	// case of duplicated descriptor, it may be called more than
+	// once for a file.
+	Flush(ctx context.Context) fuse.Status
+
+	// This is called to before the file handle is forgotten. This
+	// method has no return value, so nothing can synchronizes on
+	// the call. Any cleanup that requires specific synchronization or
+	// could fail with I/O errors should happen in Flush instead.
+	Release(ctx context.Context)
+
+	// The methods below may be called on closed files, due to
+	// concurrency.  In that case, you should return EBADF.
+	// TODO - fold into a setattr method?
+	GetAttr(ctx context.Context, out *fuse.Attr) fuse.Status
+	Truncate(ctx context.Context, size uint64) fuse.Status
+	Chown(ctx context.Context, uid uint32, gid uint32) fuse.Status
+	Chmod(ctx context.Context, perms uint32) fuse.Status
+	Utimens(ctx context.Context, atime *time.Time, mtime *time.Time) fuse.Status
+	Allocate(ctx context.Context, off uint64, size uint64, mode uint32) (code fuse.Status)
+}
+
+type Options struct {
+	Debug bool
+
+	EntryTimeout    *time.Duration
+	AttrTimeout     *time.Duration
+	NegativeTimeout *time.Duration
+}
diff --git a/nodefs/bridge.go b/nodefs/bridge.go
new file mode 100644
index 0000000000000000000000000000000000000000..76efcbe82255f0ae3e876c6175fe6c94845090b0
--- /dev/null
+++ b/nodefs/bridge.go
@@ -0,0 +1,439 @@
+// Copyright 2019 the Go-FUSE Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package nodefs
+
+import (
+	"context"
+	"sync"
+	"time"
+
+	"github.com/hanwen/go-fuse/fuse"
+)
+
+type mapEntry struct {
+	generation uint64
+	inode      *Inode
+}
+
+type fileEntry struct {
+	file File
+
+	// space to hold directory stuff
+}
+
+type rawBridge struct {
+	fuse.RawFileSystem
+
+	options Options
+	root    *Inode
+
+	mu    sync.Mutex
+	nodes []mapEntry
+	free  []uint64
+
+	files     []fileEntry
+	freeFiles []uint64
+}
+
+func NewNodeFS(root Node, opts *Options) fuse.RawFileSystem {
+	bridge := &rawBridge{
+		RawFileSystem: fuse.NewDefaultRawFileSystem(),
+	}
+
+	if opts != nil {
+		bridge.options = *opts
+	} else {
+		oneSec := time.Second
+		bridge.options.EntryTimeout = &oneSec
+		bridge.options.AttrTimeout = &oneSec
+	}
+
+	bridge.root = &Inode{
+		nodeID:      1,
+		lookupCount: 1,
+		mode:        fuse.S_IFDIR,
+		children:    make(map[string]*Inode),
+		parents:     nil,
+		node:        root,
+		bridge:      bridge,
+	}
+	root.setInode(bridge.root)
+	bridge.nodes = append(bridge.nodes,
+		mapEntry{},
+		// ID 1 is always the root.
+		mapEntry{inode: bridge.root})
+
+	// Fh 0 means no file handle.
+	bridge.files = []fileEntry{{}}
+	return bridge
+}
+
+func (b *rawBridge) inode(id uint64, fh uint64) (*Inode, fileEntry) {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+	return b.nodes[id].inode, b.files[fh]
+}
+
+func (b *rawBridge) Lookup(header *fuse.InHeader, name string, out *fuse.EntryOut) (status fuse.Status) {
+	parent, _ := b.inode(header.NodeId, 0)
+
+	child, code := parent.node.Lookup(context.TODO(), name, out)
+	if !code.Ok() {
+		if b.options.NegativeTimeout != nil {
+			out.SetEntryTimeout(*b.options.NegativeTimeout)
+		}
+		return code
+	}
+
+	b.mu.Lock()
+	defer b.mu.Unlock()
+
+	lockNodes(parent, child)
+	parent.setEntry(name, child)
+	unlockNodes(parent, child)
+
+	if child.nodeID == 0 {
+		b.registerInode(child)
+	}
+
+	out.NodeId = child.nodeID
+	out.Generation = b.nodes[child.nodeID].generation
+
+	if b.options.AttrTimeout != nil {
+		out.SetAttrTimeout(*b.options.AttrTimeout)
+	}
+	if b.options.EntryTimeout != nil {
+		out.SetEntryTimeout(*b.options.EntryTimeout)
+	}
+
+	return fuse.OK
+}
+
+func (b *rawBridge) registerInode(child *Inode) {
+	if l := len(b.free); l > 0 {
+		last := b.free[l-1]
+		b.free = b.free[:l-1]
+
+		child.nodeID = last
+		b.nodes[last].inode = child
+		b.nodes[last].generation++
+	} else {
+		last := len(b.nodes)
+		b.nodes = append(b.nodes, mapEntry{
+			inode: child,
+		})
+		child.nodeID = uint64(last)
+	}
+}
+
+func (b *rawBridge) Create(input *fuse.CreateIn, name string, out *fuse.CreateOut) (code fuse.Status) {
+	ctx := context.TODO()
+	parent, _ := b.inode(input.NodeId, 0)
+	child, f, flags, code := parent.node.Create(ctx, name, input.Flags, input.Mode)
+	if !code.Ok() {
+		if b.options.NegativeTimeout != nil {
+			out.SetEntryTimeout(*b.options.NegativeTimeout)
+		}
+		return code
+	}
+
+	b.mu.Lock()
+	defer b.mu.Unlock()
+
+	b.registerInode(child)
+
+	lockNodes(parent, child)
+	parent.setEntry(name, child)
+	unlockNodes(parent, child)
+
+	out.NodeId = child.nodeID
+	out.Generation = b.nodes[child.nodeID].generation
+
+	if b.options.AttrTimeout != nil {
+		out.SetAttrTimeout(*b.options.AttrTimeout)
+	}
+	if b.options.EntryTimeout != nil {
+		out.SetEntryTimeout(*b.options.EntryTimeout)
+	}
+
+	out.Fh = b.registerFile(f)
+	out.OpenFlags = flags
+
+	f.GetAttr(ctx, &out.Attr)
+	return fuse.OK
+}
+
+func (b *rawBridge) Forget(nodeid, nlookup uint64) {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+	n := b.nodes[nodeid].inode
+	n.lookupCount -= nlookup
+	if n.lookupCount == 0 {
+		n.clearChildren()
+		n.clearParents()
+
+		b.free = append(b.free, nodeid)
+		b.nodes[nodeid].inode = nil
+	}
+
+}
+
+func (b *rawBridge) SetDebug(debug bool) {}
+
+func (b *rawBridge) GetAttr(input *fuse.GetAttrIn, out *fuse.AttrOut) (code fuse.Status) {
+	n, fEntry := b.inode(input.NodeId, input.Fh())
+	f := fEntry.file
+
+	// nosubmit - FATTR_FH vs FUSE_GETATTR_FH ?
+	if input.Flags()&fuse.FUSE_GETATTR_FH == 0 {
+		f = nil
+	}
+
+	dest := &out.Attr
+	code = n.node.GetAttr(context.TODO(), f, dest)
+	if out.Nlink == 0 {
+		// With Nlink == 0, newer kernels will refuse link
+		// operations.
+		out.Nlink = 1
+	}
+
+	// NOSUBMIT attr timeout
+	return code
+}
+
+func (b *rawBridge) SetAttr(input *fuse.SetAttrIn, out *fuse.AttrOut) (code fuse.Status) {
+
+	ctx := context.TODO()
+
+	n, fEntry := b.inode(input.NodeId, input.Fh)
+	f := fEntry.file
+	if input.Valid&fuse.FATTR_FH == 0 {
+		f = nil
+	}
+
+	if input.Valid&fuse.FATTR_MODE != 0 {
+		permissions := uint32(07777) & input.Mode
+		code = n.node.Chmod(ctx, f, permissions)
+	}
+
+	if code.Ok() && (input.Valid&(fuse.FATTR_UID|fuse.FATTR_GID) != 0) {
+		var uid uint32 = ^uint32(0) // means "do not change" in chown(2)
+		var gid uint32 = ^uint32(0)
+		if input.Valid&fuse.FATTR_UID != 0 {
+			uid = input.Uid
+		}
+		if input.Valid&fuse.FATTR_GID != 0 {
+			gid = input.Gid
+		}
+		code = n.node.Chown(ctx, f, uid, gid)
+	}
+
+	if code.Ok() && input.Valid&fuse.FATTR_SIZE != 0 {
+		code = n.node.Truncate(ctx, f, input.Size)
+	}
+
+	if code.Ok() && (input.Valid&(fuse.FATTR_ATIME|fuse.FATTR_MTIME|fuse.FATTR_ATIME_NOW|fuse.FATTR_MTIME_NOW) != 0) {
+		now := time.Now()
+		var atime *time.Time
+		var mtime *time.Time
+
+		if input.Valid&fuse.FATTR_ATIME != 0 {
+			if input.Valid&fuse.FATTR_ATIME_NOW != 0 {
+				atime = &now
+			} else {
+				t := time.Unix(int64(input.Atime), int64(input.Atimensec))
+				atime = &t
+			}
+		}
+
+		if input.Valid&fuse.FATTR_MTIME != 0 {
+			if input.Valid&fuse.FATTR_MTIME_NOW != 0 {
+				mtime = &now
+			} else {
+				t := time.Unix(int64(input.Mtime), int64(input.Mtimensec))
+				mtime = &t
+			}
+		}
+
+		code = n.node.Utimens(ctx, f, atime, mtime)
+	}
+
+	if !code.Ok() {
+		return code
+	}
+
+	// Must call GetAttr(); the filesystem may override some of
+	// the changes we effect here.
+	attr := &out.Attr
+	code = n.node.GetAttr(ctx, f, attr)
+
+	// TODO - attr timout?
+	return code
+}
+
+func (b *rawBridge) Mknod(input *fuse.MknodIn, name string, out *fuse.EntryOut) (code fuse.Status) {
+	return fuse.ENOSYS
+}
+
+func (b *rawBridge) Mkdir(input *fuse.MkdirIn, name string, out *fuse.EntryOut) (code fuse.Status) {
+	return fuse.ENOSYS
+}
+
+func (b *rawBridge) Unlink(header *fuse.InHeader, name string) (code fuse.Status) {
+	return fuse.ENOSYS
+}
+
+func (b *rawBridge) Rmdir(header *fuse.InHeader, name string) (code fuse.Status) {
+	return fuse.ENOSYS
+}
+
+func (b *rawBridge) Rename(input *fuse.RenameIn, oldName string, newName string) (code fuse.Status) {
+	return fuse.ENOSYS
+}
+
+func (b *rawBridge) Link(input *fuse.LinkIn, filename string, out *fuse.EntryOut) (code fuse.Status) {
+	return fuse.ENOSYS
+}
+
+func (b *rawBridge) Symlink(header *fuse.InHeader, pointedTo string, linkName string, out *fuse.EntryOut) (code fuse.Status) {
+	return fuse.ENOSYS
+}
+
+func (b *rawBridge) Readlink(header *fuse.InHeader) (out []byte, code fuse.Status) {
+	return nil, fuse.ENOSYS
+}
+
+func (b *rawBridge) Access(input *fuse.AccessIn) (code fuse.Status) {
+	return fuse.ENOSYS
+}
+
+// Extended attributes.
+func (b *rawBridge) GetXAttrSize(header *fuse.InHeader, attr string) (sz int, code fuse.Status) {
+	return 0, fuse.ENOSYS
+}
+
+func (b *rawBridge) GetXAttrData(header *fuse.InHeader, attr string) (data []byte, code fuse.Status) {
+	return nil, fuse.ENOSYS
+}
+
+func (b *rawBridge) ListXAttr(header *fuse.InHeader) (attributes []byte, code fuse.Status) {
+	return nil, fuse.ENOSYS
+}
+
+func (b *rawBridge) SetXAttr(input *fuse.SetXAttrIn, attr string, data []byte) fuse.Status {
+	return fuse.ENOSYS
+}
+
+func (b *rawBridge) RemoveXAttr(header *fuse.InHeader, attr string) (code fuse.Status) {
+	return
+}
+
+func (b *rawBridge) Open(input *fuse.OpenIn, out *fuse.OpenOut) (status fuse.Status) {
+	n, _ := b.inode(input.NodeId, 0)
+	// NOSUBMIT: what about the mode argument?
+	f, flags, code := n.node.Open(context.TODO(), input.Flags)
+	if !code.Ok() {
+		return code
+	}
+
+	b.mu.Lock()
+	defer b.mu.Unlock()
+
+	out.Fh = b.registerFile(f)
+	out.OpenFlags = flags
+	return fuse.OK
+}
+
+func (b *rawBridge) registerFile(f File) uint64 {
+	var fh uint64
+	if len(b.freeFiles) > 0 {
+		last := uint64(len(b.freeFiles) - 1)
+		fh = b.freeFiles[last]
+		b.freeFiles = b.freeFiles[:last]
+	} else {
+		fh = uint64(len(b.files))
+		b.files = append(b.files, fileEntry{})
+	}
+
+	b.files[fh].file = f
+	return fh
+}
+
+func (b *rawBridge) Read(input *fuse.ReadIn, buf []byte) (fuse.ReadResult, fuse.Status) {
+	n, f := b.inode(input.NodeId, input.Fh)
+	return n.node.Read(context.TODO(), f.file, buf, int64(input.Offset))
+}
+
+func (b *rawBridge) GetLk(input *fuse.LkIn, out *fuse.LkOut) (code fuse.Status) {
+	n, f := b.inode(input.NodeId, input.Fh)
+	return n.node.GetLk(context.TODO(), f.file, input.Owner, &input.Lk, input.LkFlags, &out.Lk)
+}
+
+func (b *rawBridge) SetLk(input *fuse.LkIn) (code fuse.Status) {
+	n, f := b.inode(input.NodeId, input.Fh)
+	return n.node.SetLk(context.TODO(), f.file, input.Owner, &input.Lk, input.LkFlags)
+}
+
+func (b *rawBridge) SetLkw(input *fuse.LkIn) (code fuse.Status) {
+	n, f := b.inode(input.NodeId, input.Fh)
+	return n.node.SetLkw(context.TODO(), f.file, input.Owner, &input.Lk, input.LkFlags)
+}
+
+func (b *rawBridge) Release(input *fuse.ReleaseIn) {
+	n, f := b.inode(input.NodeId, input.Fh)
+	n.node.Release(context.TODO(), f.file)
+
+	if input.Fh > 0 {
+		b.mu.Lock()
+		defer b.mu.Unlock()
+		b.files[input.Fh].file = nil
+		b.freeFiles = append(b.freeFiles, input.Fh)
+	}
+}
+
+func (b *rawBridge) Write(input *fuse.WriteIn, data []byte) (written uint32, code fuse.Status) {
+	n, f := b.inode(input.NodeId, input.Fh)
+	return n.node.Write(context.TODO(), f.file, data, int64(input.Offset))
+}
+
+func (b *rawBridge) Flush(input *fuse.FlushIn) fuse.Status {
+	n, f := b.inode(input.NodeId, input.Fh)
+	return n.node.Flush(context.TODO(), f.file)
+}
+
+func (b *rawBridge) Fsync(input *fuse.FsyncIn) (code fuse.Status) {
+	return fuse.ENOSYS
+}
+
+func (b *rawBridge) Fallocate(input *fuse.FallocateIn) (code fuse.Status) {
+	n, f := b.inode(input.NodeId, input.Fh)
+	return n.node.Allocate(context.TODO(), f.file, input.Offset, input.Length, input.Mode)
+}
+
+func (b *rawBridge) OpenDir(input *fuse.OpenIn, out *fuse.OpenOut) (status fuse.Status) {
+	return
+}
+
+func (b *rawBridge) ReadDir(input *fuse.ReadIn, out *fuse.DirEntryList) fuse.Status {
+	return fuse.ENOSYS
+}
+
+func (b *rawBridge) ReadDirPlus(input *fuse.ReadIn, out *fuse.DirEntryList) fuse.Status {
+	return fuse.ENOSYS
+}
+
+func (b *rawBridge) ReleaseDir(input *fuse.ReleaseIn) {
+	return
+}
+
+func (b *rawBridge) FsyncDir(input *fuse.FsyncIn) (code fuse.Status) {
+	return
+}
+
+func (b *rawBridge) StatFs(input *fuse.InHeader, out *fuse.StatfsOut) (code fuse.Status) {
+	return
+}
+func (b *rawBridge) Init(*fuse.Server) {
+}
diff --git a/nodefs/default.go b/nodefs/default.go
new file mode 100644
index 0000000000000000000000000000000000000000..16355b4af3804e49ce3853a67ca2ec9a50e5aefe
--- /dev/null
+++ b/nodefs/default.go
@@ -0,0 +1,204 @@
+// Copyright 2019 the Go-FUSE Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package nodefs
+
+import (
+	"context"
+	"sync/atomic"
+	"time"
+	"unsafe"
+
+	"github.com/hanwen/go-fuse/fuse"
+)
+
+// DefaultNode provides common base Node functionality.
+//
+// It must be embedded in any Node implementation.
+type DefaultNode struct {
+	inode_ *Inode
+}
+
+// set/retrieve inode.
+//
+// node -> inode association, can be simultaneously tried to be set, if for e.g.
+//
+//	    root
+//	    /  \
+//	  dir1 dir2
+//	    \  /
+//	    file
+//
+// dir1.Lookup("file") and dir2.Lookup("file") are executed simultaneously.
+//
+// We use atomics so that only one set can win
+//
+// To read node.inode atomic.LoadPointer is used, however it is not expensive
+// since it translates to regular MOVQ on amd64.
+
+func (dn *DefaultNode) setInode(inode *Inode) bool {
+	return atomic.CompareAndSwapPointer(
+		(*unsafe.Pointer)(unsafe.Pointer(&dn.inode_)),
+		nil, unsafe.Pointer(inode))
+}
+
+func (dn *DefaultNode) inode() *Inode {
+	return (*Inode)(atomic.LoadPointer(
+		(*unsafe.Pointer)(unsafe.Pointer(&dn.inode_))))
+}
+
+func (n *DefaultNode) Read(ctx context.Context, f File, dest []byte, off int64) (fuse.ReadResult, fuse.Status) {
+	if f != nil {
+		return f.Read(ctx, dest, off)
+	}
+	return nil, fuse.ENOSYS
+}
+func (n *DefaultNode) Write(ctx context.Context, f File, data []byte, off int64) (written uint32, code fuse.Status) {
+	if f != nil {
+		return f.Write(ctx, data, off)
+	}
+
+	return 0, fuse.ENOSYS
+}
+
+func (n *DefaultNode) GetLk(ctx context.Context, f File, owner uint64, lk *fuse.FileLock, flags uint32, out *fuse.FileLock) (code fuse.Status) {
+	if f != nil {
+		return f.GetLk(ctx, owner, lk, flags, out)
+	}
+
+	return fuse.ENOSYS
+}
+
+func (n *DefaultNode) SetLk(ctx context.Context, f File, owner uint64, lk *fuse.FileLock, flags uint32) (code fuse.Status) {
+	if f != nil {
+		return f.SetLk(ctx, owner, lk, flags)
+	}
+
+	return fuse.ENOSYS
+}
+
+func (n *DefaultNode) SetLkw(ctx context.Context, f File, owner uint64, lk *fuse.FileLock, flags uint32) (code fuse.Status) {
+	if f != nil {
+		return f.SetLkw(ctx, owner, lk, flags)
+	}
+
+	return fuse.ENOSYS
+}
+func (n *DefaultNode) Flush(ctx context.Context, f File) fuse.Status {
+	if f != nil {
+		return f.Flush(ctx)
+	}
+
+	return fuse.ENOSYS
+}
+
+func (n *DefaultNode) Release(ctx context.Context, f File) {
+	if f != nil {
+		f.Release(ctx)
+	}
+}
+
+func (n *DefaultNode) Allocate(ctx context.Context, f File, off uint64, size uint64, mode uint32) (code fuse.Status) {
+	if f != nil {
+		return f.Allocate(ctx, off, size, mode)
+	}
+
+	return fuse.ENOSYS
+}
+
+func (n *DefaultNode) GetAttr(ctx context.Context, f File, out *fuse.Attr) fuse.Status {
+	if f != nil {
+		f.GetAttr(ctx, out)
+	}
+
+	return fuse.ENOSYS
+}
+
+func (n *DefaultNode) Truncate(ctx context.Context, f File, size uint64) fuse.Status {
+	if f != nil {
+		return f.Truncate(ctx, size)
+	}
+
+	return fuse.ENOSYS
+}
+
+func (n *DefaultNode) Chown(ctx context.Context, f File, uid uint32, gid uint32) fuse.Status {
+	if f != nil {
+		return f.Chown(ctx, uid, gid)
+	}
+
+	return fuse.ENOSYS
+}
+
+func (n *DefaultNode) Chmod(ctx context.Context, f File, perms uint32) fuse.Status {
+	if f != nil {
+		return f.Chmod(ctx, perms)
+	}
+
+	return fuse.ENOSYS
+}
+
+func (n *DefaultNode) Utimens(ctx context.Context, f File, atime *time.Time, mtime *time.Time) fuse.Status {
+	if f != nil {
+		return f.Utimens(ctx, atime, mtime)
+	}
+
+	return fuse.ENOSYS
+}
+
+type DefaultFile struct {
+}
+
+func (f *DefaultFile) Read(ctx context.Context, dest []byte, off int64) (fuse.ReadResult, fuse.Status) {
+	return nil, fuse.ENOSYS
+}
+
+func (f *DefaultFile) Write(ctx context.Context, data []byte, off int64) (written uint32, code fuse.Status) {
+	return 0, fuse.ENOSYS
+}
+
+func (f *DefaultFile) GetLk(ctx context.Context, owner uint64, lk *fuse.FileLock, flags uint32, out *fuse.FileLock) (code fuse.Status) {
+	return fuse.ENOSYS
+}
+
+func (f *DefaultFile) SetLk(ctx context.Context, owner uint64, lk *fuse.FileLock, flags uint32) (code fuse.Status) {
+	return fuse.ENOSYS
+}
+
+func (f *DefaultFile) SetLkw(ctx context.Context, owner uint64, lk *fuse.FileLock, flags uint32) (code fuse.Status) {
+	return fuse.ENOSYS
+}
+
+func (f *DefaultFile) Flush(ctx context.Context) fuse.Status {
+	return fuse.ENOSYS
+}
+
+func (f *DefaultFile) Release(ctx context.Context) {
+
+}
+
+func (f *DefaultFile) GetAttr(ctx context.Context, out *fuse.Attr) fuse.Status {
+	return fuse.ENOSYS
+}
+
+func (f *DefaultFile) Truncate(ctx context.Context, size uint64) fuse.Status {
+	return fuse.ENOSYS
+}
+
+func (f *DefaultFile) Chown(ctx context.Context, uid uint32, gid uint32) fuse.Status {
+	return fuse.ENOSYS
+
+}
+
+func (f *DefaultFile) Chmod(ctx context.Context, perms uint32) fuse.Status {
+	return fuse.ENOSYS
+}
+
+func (f *DefaultFile) Utimens(ctx context.Context, atime *time.Time, mtime *time.Time) fuse.Status {
+	return fuse.ENOSYS
+}
+
+func (f *DefaultFile) Allocate(ctx context.Context, off uint64, size uint64, mode uint32) (code fuse.Status) {
+	return fuse.ENOSYS
+}
diff --git a/nodefs/files.go b/nodefs/files.go
new file mode 100644
index 0000000000000000000000000000000000000000..7b28a8b2add5d3ed8e230bf3b8fe90334eff8bd6
--- /dev/null
+++ b/nodefs/files.go
@@ -0,0 +1,166 @@
+// Copyright 2019 the Go-FUSE Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package nodefs
+
+import (
+	"context"
+	//	"time"
+	"os"
+	"sync"
+	"syscall"
+
+	"github.com/hanwen/go-fuse/fuse"
+)
+
+// LoopbackFile delegates all operations back to an underlying os.File.
+func NewLoopbackFile(f *os.File) File {
+	return &loopbackFile{File: f}
+}
+
+type loopbackFile struct {
+	File *os.File
+
+	// os.File is not threadsafe. Although fd themselves are
+	// constant during the lifetime of an open file, the OS may
+	// reuse the fd number after it is closed. When open races
+	// with another close, they may lead to confusion as which
+	// file gets written in the end.
+	mu sync.Mutex
+}
+
+func (f *loopbackFile) Read(ctx context.Context, buf []byte, off int64) (res fuse.ReadResult, code fuse.Status) {
+	f.mu.Lock()
+	// This is not racy by virtue of the kernel properly
+	// synchronizing the open/write/close.
+	r := fuse.ReadResultFd(f.File.Fd(), off, len(buf))
+	f.mu.Unlock()
+	return r, fuse.OK
+}
+
+func (f *loopbackFile) Write(ctx context.Context, data []byte, off int64) (uint32, fuse.Status) {
+	f.mu.Lock()
+	n, err := f.File.WriteAt(data, off)
+	f.mu.Unlock()
+	return uint32(n), fuse.ToStatus(err)
+}
+
+func (f *loopbackFile) Release(ctx context.Context) {
+	f.mu.Lock()
+	f.File.Close()
+	f.mu.Unlock()
+}
+
+func (f *loopbackFile) Flush(ctx context.Context) fuse.Status {
+	f.mu.Lock()
+
+	// Since Flush() may be called for each dup'd fd, we don't
+	// want to really close the file, we just want to flush. This
+	// is achieved by closing a dup'd fd.
+	newFd, err := syscall.Dup(int(f.File.Fd()))
+	f.mu.Unlock()
+
+	if err != nil {
+		return fuse.ToStatus(err)
+	}
+	err = syscall.Close(newFd)
+	return fuse.ToStatus(err)
+}
+
+func (f *loopbackFile) Fsync(ctx context.Context, flags int) (code fuse.Status) {
+	f.mu.Lock()
+	r := fuse.ToStatus(syscall.Fsync(int(f.File.Fd())))
+	f.mu.Unlock()
+
+	return r
+}
+
+const (
+	F_OFD_GETLK  = 36
+	F_OFD_SETLK  = 37
+	F_OFD_SETLKW = 38
+)
+
+func (f *loopbackFile) GetLk(ctx context.Context, owner uint64, lk *fuse.FileLock, flags uint32, out *fuse.FileLock) (code fuse.Status) {
+	flk := syscall.Flock_t{}
+	lk.ToFlockT(&flk)
+	code = fuse.ToStatus(syscall.FcntlFlock(f.File.Fd(), F_OFD_GETLK, &flk))
+	out.FromFlockT(&flk)
+	return
+}
+
+func (f *loopbackFile) SetLk(ctx context.Context, owner uint64, lk *fuse.FileLock, flags uint32) (code fuse.Status) {
+	return f.setLock(ctx, owner, lk, flags, false)
+}
+
+func (f *loopbackFile) SetLkw(ctx context.Context, owner uint64, lk *fuse.FileLock, flags uint32) (code fuse.Status) {
+	return f.setLock(ctx, owner, lk, flags, true)
+}
+
+func (f *loopbackFile) setLock(ctx context.Context, owner uint64, lk *fuse.FileLock, flags uint32, blocking bool) (code fuse.Status) {
+	if (flags & fuse.FUSE_LK_FLOCK) != 0 {
+		var op int
+		switch lk.Typ {
+		case syscall.F_RDLCK:
+			op = syscall.LOCK_SH
+		case syscall.F_WRLCK:
+			op = syscall.LOCK_EX
+		case syscall.F_UNLCK:
+			op = syscall.LOCK_UN
+		default:
+			return fuse.EINVAL
+		}
+		if !blocking {
+			op |= syscall.LOCK_NB
+		}
+		return fuse.ToStatus(syscall.Flock(int(f.File.Fd()), op))
+	} else {
+		flk := syscall.Flock_t{}
+		lk.ToFlockT(&flk)
+		var op int
+		if blocking {
+			op = F_OFD_SETLKW
+		} else {
+			op = F_OFD_SETLK
+		}
+		return fuse.ToStatus(syscall.FcntlFlock(f.File.Fd(), op, &flk))
+	}
+}
+
+func (f *loopbackFile) Truncate(ctx context.Context, size uint64) fuse.Status {
+	f.mu.Lock()
+	r := fuse.ToStatus(syscall.Ftruncate(int(f.File.Fd()), int64(size)))
+	f.mu.Unlock()
+
+	return r
+}
+
+func (f *loopbackFile) Chmod(ctx context.Context, mode uint32) fuse.Status {
+	f.mu.Lock()
+	r := fuse.ToStatus(f.File.Chmod(os.FileMode(mode)))
+	f.mu.Unlock()
+
+	return r
+}
+
+func (f *loopbackFile) Chown(ctx context.Context, uid uint32, gid uint32) fuse.Status {
+	f.mu.Lock()
+	r := fuse.ToStatus(f.File.Chown(int(uid), int(gid)))
+	f.mu.Unlock()
+
+	return r
+}
+
+func (f *loopbackFile) GetAttr(ctx context.Context, a *fuse.Attr) fuse.Status {
+	st := syscall.Stat_t{}
+	f.mu.Lock()
+	err := syscall.Fstat(int(f.File.Fd()), &st)
+	f.mu.Unlock()
+	if err != nil {
+		return fuse.ToStatus(err)
+	}
+	a.FromStat(&st)
+
+	return fuse.OK
+}
diff --git a/nodefs/files_linux.go b/nodefs/files_linux.go
new file mode 100644
index 0000000000000000000000000000000000000000..dcf8699e77b021d85b1695ea86d0fe772fe5e511
--- /dev/null
+++ b/nodefs/files_linux.go
@@ -0,0 +1,36 @@
+// Copyright 2019 the Go-FUSE Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package nodefs
+
+import (
+	"context"
+	"syscall"
+	"time"
+
+	"github.com/hanwen/go-fuse/fuse"
+)
+
+func (f *loopbackFile) Allocate(ctx context.Context, off uint64, sz uint64, mode uint32) fuse.Status {
+	f.mu.Lock()
+	err := syscall.Fallocate(int(f.File.Fd()), mode, int64(off), int64(sz))
+	f.mu.Unlock()
+	if err != nil {
+		return fuse.ToStatus(err)
+	}
+	return fuse.OK
+}
+
+// Utimens - file handle based version of loopbackFileSystem.Utimens()
+func (f *loopbackFile) Utimens(ctx context.Context, a *time.Time, m *time.Time) fuse.Status {
+	var ts [2]syscall.Timespec
+	ts[0] = fuse.UtimeToTimespec(a)
+	ts[1] = fuse.UtimeToTimespec(m)
+	f.mu.Lock()
+	var err error
+	// NOSUBMIT
+	//	err := futimens(int(f.File.Fd()), &ts)
+	f.mu.Unlock()
+	return fuse.ToStatus(err)
+}
diff --git a/nodefs/inode.go b/nodefs/inode.go
new file mode 100644
index 0000000000000000000000000000000000000000..2c82a48d2fe2dd2160a6b313458a61d24821a3c2
--- /dev/null
+++ b/nodefs/inode.go
@@ -0,0 +1,333 @@
+// Copyright 2019 the Go-FUSE Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package nodefs
+
+import (
+	"log"
+	"sort"
+	"strings"
+	"sync"
+	"unsafe"
+
+	"github.com/hanwen/go-fuse/fuse"
+)
+
+var _ = log.Println
+
+type parentData struct {
+	name   string
+	parent *Inode
+}
+
+// Inode is a node in VFS tree.  Inodes are one-to-one mapped to Node
+// instances, which is the extension interface for file systems.  One
+// can create fully-formed trees of Inodes ahead of time by creating
+// "persistent" Inodes.
+type Inode struct {
+	// The filetype bits from the mode.
+	mode     uint32
+	opaqueID uint64
+	node     Node
+	bridge   *rawBridge
+
+	// Following data is mutable.
+
+	// the following fields protected by bridge.mu
+
+	// ID of the inode; 0 if inode was forgotten.
+	// forgotten inodes are unlinked from parent and children, but could be
+	// still not yet removed from bridge.nodes .
+	lookupCount uint64
+	nodeID      uint64
+
+	// mu protects the following mutable fields. When locking
+	// multiple Inodes, locks must be acquired using
+	// lockNodes/unlockNodes
+	mu sync.Mutex
+
+	// changeCounter increments every time the below mutable state
+	// (lookupCount, nodeID, children, parents) is modified.
+	//
+	// This is used in places where we have to relock inode into inode
+	// group lock, and after locking the group we have to check if inode
+	// did not changed, and if it changed - retry the operation.
+	changeCounter uint32
+
+	children map[string]*Inode
+	parents  map[parentData]struct{}
+}
+
+// newInode creates creates new inode pointing to node.
+//
+// node -> inode association is NOT set.
+// the inode is _not_ yet has
+func newInode(node Node, mode uint32) *Inode {
+	inode := &Inode{
+		mode:    mode ^ 07777,
+		node:    node,
+		parents: make(map[parentData]struct{}),
+	}
+	if mode&fuse.S_IFDIR != 0 {
+		inode.children = make(map[string]*Inode)
+	}
+	return inode
+}
+
+// sortNodes rearranges inode group in consistent order.
+//
+// The nodes are ordered by their in-RAM address, which gives consistency
+// property: for any A and B inodes, sortNodes will either always order A < B,
+// or always order A > B.
+//
+// See lockNodes where this property is used to avoid deadlock when taking
+// locks on inode group.
+func sortNodes(ns []*Inode) {
+	sort.Slice(ns, func(i, j int) bool {
+		return uintptr(unsafe.Pointer(ns[i])) < uintptr(unsafe.Pointer(ns[j]))
+	})
+}
+
+// lockNodes locks group of inodes.
+//
+// It always lock the inodes in the same order - to avoid deadlocks.
+// It also avoids locking an inode more than once, if it was specified multiple times.
+// An example when an inode might be given multiple times is if dir/a and dir/b
+// are hardlinked to the same inode and the caller needs to take locks on dir children.
+//
+// It is valid to give nil nodes - those are simply ignored.
+func lockNodes(ns ...*Inode) {
+	sortNodes(ns)
+
+	// The default value nil prevents trying to lock nil nodes.
+	var nprev *Inode
+	for _, n := range ns {
+		if n != nprev {
+			n.mu.Lock()
+			nprev = n
+		}
+	}
+}
+
+// unlockNodes releases locks taken by lockNodes.
+func unlockNodes(ns ...*Inode) {
+	// we don't need to unlock in the same order that was used in lockNodes.
+	// however it still helps to have nodes sorted to avoid duplicates.
+	sortNodes(ns)
+
+	var nprev *Inode
+	for _, n := range ns {
+		if n != nprev {
+			n.mu.Unlock()
+			nprev = n
+		}
+	}
+}
+
+// Forgotten returns true if the kernel holds no references to this
+// inode.  This can be used for background cleanup tasks, since the
+// kernel has no way of reviving forgotten nodes by its own
+// initiative.
+func (n *Inode) Forgotten() bool {
+	n.bridge.mu.Lock()
+	defer n.bridge.mu.Unlock()
+	return n.lookupCount == 0
+}
+
+// Node returns the Node object implementing the file system operations.
+func (n *Inode) Node() Node {
+	return n.node
+}
+
+// Path returns a path string to the inode relative to the root.
+func (n *Inode) Path(root *Inode) string {
+	var segments []string
+	p := n
+	for p != nil && p != root {
+		var pd parentData
+
+		// We don't try to take all locks at the same time, because
+		// the caller won't use the "path" string under lock anyway.
+		p.mu.Lock()
+		for pd = range p.parents {
+			break
+		}
+		p.mu.Unlock()
+		if pd.parent == nil {
+			break
+		}
+
+		segments = append(segments, pd.name)
+		p = pd.parent
+	}
+
+	if p == nil {
+		// NOSUBMIT - should replace rather than append?
+		segments = append(segments, ".deleted")
+	}
+
+	i := 0
+	j := len(segments) - 1
+
+	for i < j {
+		segments[i], segments[j] = segments[j], segments[i]
+		i++
+		j--
+	}
+
+	path := strings.Join(segments, "/")
+	return path
+}
+
+// Finds a child with the given name and filetype.  Returns nil if not
+// found.
+func (n *Inode) FindChildByMode(name string, mode uint32) *Inode {
+	mode ^= 07777
+
+	n.mu.Lock()
+	defer n.mu.Unlock()
+
+	ch := n.children[name]
+
+	if ch != nil && ch.mode == mode {
+		return ch
+	}
+
+	return nil
+}
+
+// Finds a child with the given name and ID. Returns nil if not found.
+func (n *Inode) FindChildByOpaqueID(name string, opaqueID uint64) *Inode {
+	n.mu.Lock()
+	defer n.mu.Unlock()
+
+	ch := n.children[name]
+
+	if ch != nil && ch.opaqueID == opaqueID {
+		return ch
+	}
+
+	return nil
+}
+
+// setEntry does `iparent[name] = ichild` linking.
+//
+// setEntry must not be called simultaneously for any of iparent or ichild.
+// This, for example could be satisfied if both iparent and ichild are locked,
+// but it could be also valid if only iparent is locked and ichild was just
+// created and only one goroutine keeps referencing it.
+//
+// XXX also ichild.lookupCount++ ?
+func (iparent *Inode) setEntry(name string, ichild *Inode) {
+	ichild.parents[parentData{name, iparent}] = struct{}{}
+	iparent.children[name] = ichild
+	ichild.changeCounter++
+	iparent.changeCounter++
+}
+
+func (n *Inode) clearParents() {
+	for {
+		lockme := []*Inode{n}
+		n.mu.Lock()
+		ts := n.changeCounter
+		for p := range n.parents {
+			lockme = append(lockme, p.parent)
+		}
+		n.mu.Unlock()
+
+		lockNodes(lockme...)
+		success := false
+		if ts == n.changeCounter {
+			for p := range n.parents {
+				delete(p.parent.children, p.name)
+				p.parent.changeCounter++
+			}
+			n.parents = map[parentData]struct{}{}
+			n.changeCounter++
+			success = true
+		}
+		unlockNodes(lockme...)
+
+		if success {
+			return
+		}
+	}
+}
+
+func (n *Inode) clearChildren() {
+	if n.mode != fuse.S_IFDIR {
+		return
+	}
+
+	var lockme []*Inode
+	for {
+		lockme = append(lockme[:0], n)
+
+		n.mu.Lock()
+		ts := n.changeCounter
+		for _, ch := range n.children {
+			lockme = append(lockme, ch)
+		}
+		n.mu.Unlock()
+
+		lockNodes(lockme...)
+		success := false
+		if ts == n.changeCounter {
+			for nm, ch := range n.children {
+				delete(ch.parents, parentData{nm, n})
+				ch.changeCounter++
+			}
+			n.children = map[string]*Inode{}
+			n.changeCounter++
+			success = true
+		}
+		unlockNodes(lockme...)
+
+		if success {
+			break
+		}
+	}
+
+	// XXX not right - we cannot fully clear our children, because they can
+	// be also children of another directory.
+	//
+	// XXX also not right - the kernel can send FORGET(idir) but keep
+	// references to children inodes.
+	for _, ch := range lockme {
+		if ch != n {
+			ch.clearChildren()
+		}
+	}
+}
+
+// NewPersistentInode returns an Inode with a LookupCount == 1, ie. the
+// node will only get garbage collected if the kernel issues a forget
+// on any of its parents.
+func (n *Inode) NewPersistentInode(node Node, mode uint32, opaque uint64) *Inode {
+	ch := n.NewInode(node, mode, opaque)
+	ch.lookupCount++
+	return ch
+}
+
+// NewInode returns an inode for the given Node. The mode should be
+// standard mode argument (eg. S_IFDIR). The opaqueID argument can be
+// used to signal changes in the tree structure during lookup (see
+// FindChildByOpaqueID). For a loopback file system, the inode number
+// of the underlying file is a good candidate.
+func (n *Inode) NewInode(node Node, mode uint32, opaqueID uint64) *Inode {
+	ch := &Inode{
+		mode:    mode ^ 07777,
+		node:    node,
+		bridge:  n.bridge,
+		parents: make(map[parentData]struct{}),
+	}
+	if mode&fuse.S_IFDIR != 0 {
+		ch.children = make(map[string]*Inode)
+	}
+	if node.setInode(ch) {
+		return ch
+	}
+
+	return node.inode()
+}
diff --git a/nodefs/loopback.go b/nodefs/loopback.go
new file mode 100644
index 0000000000000000000000000000000000000000..612016c2cc878a419233f87c1ab47ae1fb8b8977
--- /dev/null
+++ b/nodefs/loopback.go
@@ -0,0 +1,117 @@
+// Copyright 2019 the Go-FUSE Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package nodefs
+
+import (
+	"context"
+	"os"
+	"path/filepath"
+	"syscall"
+
+	"github.com/hanwen/go-fuse/fuse"
+)
+
+type loopbackRoot struct {
+	loopbackNode
+
+	root string
+}
+
+func (n *loopbackRoot) GetAttr(ctx context.Context, f File, out *fuse.Attr) fuse.Status {
+	var err error = nil
+	st := syscall.Stat_t{}
+	err = syscall.Stat(n.root, &st)
+	if err != nil {
+		return fuse.ToStatus(err)
+	}
+	out.FromStat(&st)
+	return fuse.OK
+}
+
+type loopbackNode struct {
+	DefaultNode
+
+	rootNode *loopbackRoot
+}
+
+func (n *loopbackNode) path() string {
+	path := InodeOf(n).Path(nil)
+	return filepath.Join(n.rootNode.root, path)
+}
+
+func (n *loopbackNode) Lookup(ctx context.Context, name string, out *fuse.EntryOut) (*Inode, fuse.Status) {
+	p := filepath.Join(n.path(), name)
+
+	st := syscall.Stat_t{}
+	err := syscall.Lstat(p, &st)
+	if err != nil {
+		return nil, fuse.ToStatus(err)
+	}
+
+	out.Attr.FromStat(&st)
+
+	ch := InodeOf(n).FindChildByOpaqueID(name, out.Attr.Ino)
+	if ch != nil {
+		return ch, fuse.OK
+	}
+
+	node := &loopbackNode{rootNode: n.rootNode}
+	ch = n.inode().NewInode(node, out.Attr.Mode, out.Attr.Ino)
+	return ch, fuse.OK
+}
+
+func (n *loopbackNode) Create(ctx context.Context, name string, flags uint32, mode uint32) (inode *Inode, fh File, fuseFlags uint32, code fuse.Status) {
+	p := filepath.Join(n.path(), name)
+
+	f, err := os.OpenFile(p, int(flags)|os.O_CREATE, os.FileMode(mode))
+	if err != nil {
+		return nil, nil, 0, fuse.ToStatus(err)
+	}
+
+	st := syscall.Stat_t{}
+	if err := syscall.Fstat(int(f.Fd()), &st); err != nil {
+		f.Close()
+		return nil, nil, 0, fuse.ToStatus(err)
+	}
+
+	node := &loopbackNode{rootNode: n.rootNode}
+	ch := n.inode().NewInode(node, st.Mode, st.Ino)
+	return ch, NewLoopbackFile(f), 0, fuse.OK
+}
+
+func (n *loopbackNode) Open(ctx context.Context, flags uint32) (fh File, fuseFlags uint32, code fuse.Status) {
+	p := n.path()
+	f, err := os.OpenFile(p, int(flags), 0)
+	if err != nil {
+		return nil, 0, fuse.ToStatus(err)
+	}
+	return NewLoopbackFile(f), 0, fuse.OK
+}
+
+func (n *loopbackNode) GetAttr(ctx context.Context, f File, out *fuse.Attr) fuse.Status {
+	if f != nil {
+		return f.GetAttr(ctx, out)
+	}
+
+	p := n.path()
+
+	var err error = nil
+	st := syscall.Stat_t{}
+	err = syscall.Lstat(p, &st)
+	if err != nil {
+		return fuse.ToStatus(err)
+	}
+	out.FromStat(&st)
+	return fuse.OK
+}
+
+func NewLoopback(root string) Node {
+	n := &loopbackRoot{
+		root: root,
+	}
+	n.rootNode = n
+
+	return n
+}
diff --git a/nodefs/simple_test.go b/nodefs/simple_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..f897e373e2cffa3e24cca1d8a6de6c1e046c9ad5
--- /dev/null
+++ b/nodefs/simple_test.go
@@ -0,0 +1,218 @@
+// Copyright 2019 the Go-FUSE Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package nodefs
+
+import (
+	"bytes"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"runtime"
+	"testing"
+	"time"
+
+	"github.com/hanwen/go-fuse/fuse"
+	"github.com/hanwen/go-fuse/internal/testutil"
+)
+
+type testCase struct {
+	*testing.T
+
+	dir     string
+	origDir string
+	mntDir  string
+
+	rawFS  fuse.RawFileSystem
+	server *fuse.Server
+}
+
+func (tc *testCase) writeOrig(path, content string, mode os.FileMode) {
+	if err := ioutil.WriteFile(filepath.Join(tc.origDir, path), []byte(content), mode); err != nil {
+		tc.Fatal(err)
+	}
+}
+
+func (tc *testCase) Clean() {
+	if err := tc.server.Unmount(); err != nil {
+		tc.Fatal(err)
+	}
+	if err := os.RemoveAll(tc.dir); err != nil {
+		tc.Fatal(err)
+	}
+}
+
+func newTestCase(t *testing.T) *testCase {
+	tc := &testCase{
+		dir: testutil.TempDir(),
+		T:   t,
+	}
+
+	tc.origDir = tc.dir + "/orig"
+	tc.mntDir = tc.dir + "/mnt"
+	if err := os.Mkdir(tc.origDir, 0755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.Mkdir(tc.mntDir, 0755); err != nil {
+		t.Fatal(err)
+	}
+
+	loopback := NewLoopback(tc.origDir)
+	oneSec := time.Second
+	tc.rawFS = NewNodeFS(loopback, &Options{
+		Debug:        testutil.VerboseTest(),
+		EntryTimeout: &oneSec,
+		AttrTimeout:  &oneSec,
+	})
+
+	var err error
+	tc.server, err = fuse.NewServer(tc.rawFS, tc.mntDir,
+		&fuse.MountOptions{
+			Debug: testutil.VerboseTest(),
+		})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	go tc.server.Serve()
+	if err := tc.server.WaitMount(); err != nil {
+		t.Fatal(err)
+	}
+	return tc
+}
+
+func TestBasic(t *testing.T) {
+	tc := newTestCase(t)
+	defer tc.Clean()
+
+	tc.writeOrig("file", "hello", 0644)
+
+	fi, err := os.Lstat(tc.mntDir + "/file")
+	if err != nil {
+		t.Fatalf("Lstat: %v", err)
+	}
+
+	if fi.Size() != 5 {
+		t.Errorf("got size %d want 5", fi.Size())
+	}
+
+	stat := fuse.ToStatT(fi)
+	if got, want := stat.Mode, uint32(fuse.S_IFREG|0644); got != want {
+		t.Errorf("got mode %o, want %o", got, want)
+	}
+}
+
+func TestFile(t *testing.T) {
+	tc := newTestCase(t)
+	defer tc.Clean()
+
+	content := []byte("hello world")
+	fn := tc.mntDir + "/file"
+
+	if err := ioutil.WriteFile(fn, content, 0755); err != nil {
+		t.Fatalf("WriteFile: %v", err)
+	}
+
+	if got, err := ioutil.ReadFile(fn); err != nil {
+		t.Fatalf("ReadFile: %v", err)
+	} else if bytes.Compare(got, content) != 0 {
+		t.Errorf("got %q, want %q", got, content)
+	}
+
+	f, err := os.Open(fn)
+	if err != nil {
+		t.Fatalf("Open: %v", err)
+	}
+
+	defer f.Close()
+
+	fi, err := f.Stat()
+
+	if err != nil {
+		t.Fatalf("Fstat: %v", err)
+	} else if int(fi.Size()) != len(content) {
+		t.Errorf("got size %d want 5", fi.Size())
+	}
+
+	stat := fuse.ToStatT(fi)
+	if got, want := stat.Mode, uint32(fuse.S_IFREG|0755); got != want {
+		t.Errorf("Fstat: got mode %o, want %o", got, want)
+	}
+
+	if err := f.Close(); err != nil {
+		t.Errorf("Close: %v", err)
+	}
+}
+
+func TestFileTruncate(t *testing.T) {
+	tc := newTestCase(t)
+	defer tc.Clean()
+
+	content := []byte("hello world")
+
+	if err := ioutil.WriteFile(tc.origDir+"/file", content, 0755); err != nil {
+		t.Fatalf("WriteFile: %v", err)
+	}
+
+	f, err := os.OpenFile(tc.mntDir+"/file", os.O_RDWR, 0644)
+	if err != nil {
+		t.Fatalf("Open: %v", err)
+	}
+	defer f.Close()
+
+	const trunc = 5
+	if err := f.Truncate(5); err != nil {
+		t.Errorf("Truncate: %v", err)
+	}
+
+	if err := f.Close(); err != nil {
+		t.Errorf("Close: %v", err)
+	}
+
+	if got, err := ioutil.ReadFile(tc.origDir + "/file"); err != nil {
+		t.Fatalf("ReadFile: %v", err)
+	} else if want := content[:trunc]; bytes.Compare(got, want) != 0 {
+		t.Errorf("got %q, want %q", got, want)
+	}
+}
+
+func TestFileFdLeak(t *testing.T) {
+	tc := newTestCase(t)
+	defer func() {
+		if tc != nil {
+			tc.Clean()
+		}
+	}()
+
+	content := []byte("hello world")
+
+	if err := ioutil.WriteFile(tc.origDir+"/file", content, 0755); err != nil {
+		t.Fatalf("WriteFile: %v", err)
+	}
+
+	for i := 0; i < 100; i++ {
+		if _, err := ioutil.ReadFile(tc.mntDir + "/file"); err != nil {
+			t.Fatalf("ReadFile: %v", err)
+		}
+	}
+
+	if runtime.GOOS == "linux" {
+		infos, err := ioutil.ReadDir("/proc/self/fd")
+		if err != nil {
+			t.Errorf("ReadDir %v", err)
+		}
+
+		if len(infos) > 15 {
+			t.Errorf("found %d open file descriptors for 100x ReadFile", len(infos))
+		}
+	}
+
+	tc.Clean()
+	bridge := tc.rawFS.(*rawBridge)
+	tc = nil
+
+	if got := len(bridge.files); got > 3 {
+		t.Errorf("found %d used file handles, should be <= 3", got)
+	}
+}