Repository: tetratelabs/wazero
Pull request #571 (2022-05-17 07:49:46)

Allow passing fs.FS when calling functions
## Issue
Close https://github.com/tetratelabs/wazero/issues/563

## ToDo
- [x] FSContext
- [x] FSConfig
- [x] FSKey
- [x] Override fs.FS in WASI
- [x] Add an example
- [x] Unit test
- [x] Example test

codefromthecrypt: taking a look now, thanks!
codefromthecrypt: ping me when you are ready for the next round. I'll keep a look out
knqyf263: @codefromthecrypt I think I finished reflecting your comments. There are a few concerns though.

1. I've added a test for WithFS to PathOpen only.
  - I'm not sure if it is enough or you may prefer another approach
2. FSConfig is still embedded into `ModuleConfig`
  - Do you think it should be extracted?
codefromthecrypt: @mathetake I think this is on the right track. I'm going to be mostly away this evening until tomorrow. Can you make sure this gets past the finish line. Cheers!
mathetake: I think this is on the right track :D 
knqyf263: >Meanwhile once done experimenting, maybe roll back the changes to the examples/wasi as we don't put experiemental things in that dir.

Reverted. https://github.com/tetratelabs/wazero/pull/571/commits/56714b8862959f661a9f408c8b61c774b2079af4
knqyf263: >The main test needed would be to ensure that the context FS overrides vs adds to the one you expect. So one neat way to prove that is to have a test with the base FS having two files and the overlay having only one with a different content.

I wanted to do that, but I didn't find a good place to add the test. Also, it appeared to require somewhat larger changes. I probably missed something.

BTW, please feel free to add any commits. Sometimes it's faster to write code than to explain it in writing.
codefromthecrypt: @knqyf263 thanks for all the work here. I'll be at the conference all day, but I can review and add commits to address any things, possibly today but more likely by tomorrow. In any case thanks for putting this together as you made it possible to land during a busy time 👍 
codefromthecrypt: ps I started fiddling with this, but might not push commits until tomorrow. Main thing I noticed was tension in the packages and made internal/fs instead. There are some tests in sys_test.go that should move into there also. I'll do it!
codefromthecrypt: I landed the other PRs that would drift this, and ran out of time to finish this up (still at conference and should meet people :) ). I'll finish this soon
codefromthecrypt: I force pushed a squash of the preceding work because the merges were getting hairy. I'll add a commit later with the changes I mentioned above
codefromthecrypt: @mathetake I finished re-org, can you take a look and add a commit as needed? otherwise merge it.

Patch:
diff --git a/config.go b/config.go
index 21b49572..a31e498e 100644
--- a/config.go
+++ b/config.go
@@ -3,7 +3,6 @@ package wazero
 import (
 	"context"
 	"errors"
-	"fmt"
 	"io"
 	"io/fs"
 	"math"
@@ -11,6 +10,7 @@ import (
 	"github.com/tetratelabs/wazero/api"
 	"github.com/tetratelabs/wazero/internal/engine/compiler"
 	"github.com/tetratelabs/wazero/internal/engine/interpreter"
+	fs2 "github.com/tetratelabs/wazero/internal/fs"
 	"github.com/tetratelabs/wazero/internal/wasm"
 )
 
@@ -452,21 +452,15 @@ type moduleConfig struct {
 	// environKeys allow overwriting of existing values.
 	environKeys map[string]int
 
-	// preopenFD has the next FD number to use
-	preopenFD uint32
-	// preopens are keyed on file descriptor and only include the Path and FS fields.
-	preopens map[uint32]*wasm.FileEntry
-	// preopenPaths allow overwriting of existing paths.
-	preopenPaths map[string]uint32
+	fs *fs2.FSConfig
 }
 
 func NewModuleConfig() ModuleConfig {
 	return &moduleConfig{
 		startFunctions: []string{"_start"},
 		environKeys:    map[string]int{},
-		preopenFD:      uint32(3), // after stdin/stdout/stderr
-		preopens:       map[uint32]*wasm.FileEntry{},
-		preopenPaths:   map[string]uint32{},
+
+		fs: fs2.NewFSConfig(),
 	}
 }
 
@@ -493,7 +487,7 @@ func (c *moduleConfig) WithEnv(key, value string) ModuleConfig {
 // WithFS implements ModuleConfig.WithFS
 func (c *moduleConfig) WithFS(fs fs.FS) ModuleConfig {
 	ret := *c // copy
-	ret.setFS("/", fs)
+	ret.fs = ret.fs.WithFS(fs)
 	return &ret
 }
 
@@ -542,23 +536,10 @@ func (c *moduleConfig) WithRandSource(source io.Reader) ModuleConfig {
 // WithWorkDirFS implements ModuleConfig.WithWorkDirFS
 func (c *moduleConfig) WithWorkDirFS(fs fs.FS) ModuleConfig {
 	ret := *c // copy
-	ret.setFS(".", fs)
+	ret.fs = ret.fs.WithWorkDirFS(fs)
 	return &ret
 }
 
-// setFS maps a path to a file-system. This is only used for base paths: "/" and ".".
-func (c *moduleConfig) setFS(path string, fs fs.FS) {
-	// Check to see if this key already exists and update it.
-	entry := &wasm.FileEntry{Path: path, FS: fs}
-	if fd, ok := c.preopenPaths[path]; ok {
-		c.preopens[fd] = entry
-	} else {
-		c.preopens[c.preopenFD] = entry
-		c.preopenPaths[path] = c.preopenFD
-		c.preopenFD++
-	}
-}
-
 // toSysContext creates a baseline wasm.SysContext configured by ModuleConfig.
 func (c *moduleConfig) toSysContext() (sys *wasm.SysContext, err error) {
 	var environ []string // Intentionally doesn't pre-allocate to reduce logic to default to nil.
@@ -578,24 +559,9 @@ func (c *moduleConfig) toSysContext() (sys *wasm.SysContext, err error) {
 		environ = append(environ, key+"="+value)
 	}
 
-	// Ensure no-one set a nil FD. We do this here instead of at the call site to allow chaining as nil is unexpected.
-	rootFD := uint32(0) // zero is invalid
-	setWorkDirFS := false
-	preopens := c.preopens
-	for fd, entry := range preopens {
-		if entry.FS == nil {
-			err = fmt.Errorf("FS for %s is nil", entry.Path)
-			return
-		} else if entry.Path == "/" {
-			rootFD = fd
-		} else if entry.Path == "." {
-			setWorkDirFS = true
-		}
-	}
-
-	// Default the working directory to the root FS if it exists.
-	if rootFD != 0 && !setWorkDirFS {
-		preopens[c.preopenFD] = &wasm.FileEntry{Path: ".", FS: preopens[rootFD].FS}
+	preopens, err := c.fs.Preopens()
+	if err != nil {
+		return nil, err
 	}
 
 	return wasm.NewSysContext(math.MaxUint32, c.args, environ, c.stdin, c.stdout, c.stderr, c.randSource, preopens)
diff --git a/experimental/fs.go b/experimental/fs.go
new file mode 100644
index 00000000..b00bd3ee
--- /dev/null
+++ b/experimental/fs.go
@@ -0,0 +1,21 @@
+package experimental
+
+import (
+	"context"
+	"io/fs"
+
+	"github.com/tetratelabs/wazero/api"
+	internalfs "github.com/tetratelabs/wazero/internal/fs"
+)
+
+// WithFS overrides fs.FS in the context-based manner. Caller needs to take responsibility for closing the filesystem.
+func WithFS(ctx context.Context, fs fs.FS) (context.Context, api.Closer, error) {
+	fsConfig := internalfs.NewFSConfig().WithFS(fs)
+	preopens, err := fsConfig.Preopens()
+	if err != nil {
+		return nil, nil, err
+	}
+
+	fsCtx := internalfs.NewContext(preopens)
+	return context.WithValue(ctx, internalfs.Key{}, fsCtx), fsCtx, nil
+}
diff --git a/internal/fs/fs.go b/internal/fs/fs.go
new file mode 100644
index 00000000..fb25de87
--- /dev/null
+++ b/internal/fs/fs.go
@@ -0,0 +1,172 @@
+package fs
+
+import (
+	"context"
+	"fmt"
+	"io/fs"
+	"math"
+	"sync/atomic"
+)
+
+// Key is a context.Context Value key. It allows overriding fs.FS for WASI.
+//
+// See https://github.com/tetratelabs/wazero/issues/491
+type Key struct{}
+
+// FileEntry maps a path to an open file in a file system.
+//
+// Note: This does not introduce cycles because the types here are in the package "wasi" not "internalwasi".
+type FileEntry struct {
+	Path string
+	FS   fs.FS
+	// File when nil this is a mount like "." or "/".
+	File fs.File
+}
+
+type Context struct {
+	// openedFiles is a map of file descriptor numbers (>=3) to open files (or directories) and defaults to empty.
+	// TODO: This is unguarded, so not goroutine-safe!
+	openedFiles map[uint32]*FileEntry
+
+	// lastFD is not meant to be read directly. Rather by nextFD.
+	lastFD uint32
+}
+
+func NewContext(openedFiles map[uint32]*FileEntry) *Context {
+	var fsCtx Context
+	if openedFiles == nil {
+		fsCtx.openedFiles = map[uint32]*FileEntry{}
+		fsCtx.lastFD = 2 // STDERR
+	} else {
+		fsCtx.openedFiles = openedFiles
+		fsCtx.lastFD = 2 // STDERR
+		for fd := range openedFiles {
+			if fd > fsCtx.lastFD {
+				fsCtx.lastFD = fd
+			}
+		}
+	}
+	return &fsCtx
+}
+
+// nextFD gets the next file descriptor number in a goroutine safe way (monotonically) or zero if we ran out.
+// TODO: opendFiles is still not goroutine safe!
+// TODO: This can return zero if we ran out of file descriptors. A future change can optimize by re-using an FD pool.
+func (c *Context) nextFD() uint32 {
+	if c.lastFD == math.MaxUint32 {
+		return 0
+	}
+	return atomic.AddUint32(&c.lastFD, 1)
+}
+
+// Close implements io.Closer
+func (c *Context) Close(_ context.Context) (err error) {
+	// Close any files opened in this context
+	for fd, entry := range c.openedFiles {
+		delete(c.openedFiles, fd)
+		if entry.File != nil { // File is nil for a mount like "." or "/"
+			if e := entry.File.Close(); e != nil {
+				err = e // This means the err returned == the last non-nil error.
+			}
+		}
+	}
+	return
+}
+
+// CloseFile returns true if a file was opened and closed without error, or false if not.
+func (c *Context) CloseFile(fd uint32) (bool, error) {
+	f, ok := c.openedFiles[fd]
+	if !ok {
+		return false, nil
+	}
+	delete(c.openedFiles, fd)
+
+	if f.File == nil { // TODO: currently, this means it is a pre-opened filesystem, but this may change later.
+		return true, nil
+	}
+	if err := f.File.Close(); err != nil {
+		return false, err
+	}
+	return true, nil
+}
+
+// OpenedFile returns a file and true if it was opened or nil and false, if not.
+func (c *Context) OpenedFile(fd uint32) (*FileEntry, bool) {
+	f, ok := c.openedFiles[fd]
+	return f, ok
+}
+
+// OpenFile returns the file descriptor of the new file or false if we ran out of file descriptors
+func (c *Context) OpenFile(f *FileEntry) (uint32, bool) {
+	newFD := c.nextFD()
+	if newFD == 0 {
+		return 0, false
+	}
+	c.openedFiles[newFD] = f
+	return newFD, true
+}
+
+type FSConfig struct {
+	// preopenFD has the next FD number to use
+	preopenFD uint32
+	// preopens are keyed on file descriptor and only include the Path and FS fields.
+	preopens map[uint32]*FileEntry
+	// preopenPaths allow overwriting of existing paths.
+	preopenPaths map[string]uint32
+}
+
+func NewFSConfig() *FSConfig {
+	return &FSConfig{
+		preopenFD:    uint32(3), // after stdin/stdout/stderr
+		preopens:     map[uint32]*FileEntry{},
+		preopenPaths: map[string]uint32{},
+	}
+}
+
+// setFS maps a path to a file-system. This is only used for base paths: "/" and ".".
+func (c *FSConfig) setFS(path string, fs fs.FS) {
+	// Check to see if this key already exists and update it.
+	entry := &FileEntry{Path: path, FS: fs}
+	if fd, ok := c.preopenPaths[path]; ok {
+		c.preopens[fd] = entry
+	} else {
+		c.preopens[c.preopenFD] = entry
+		c.preopenPaths[path] = c.preopenFD
+		c.preopenFD++
+	}
+}
+
+func (c *FSConfig) WithFS(fs fs.FS) *FSConfig {
+	ret := *c // copy
+	ret.setFS("/", fs)
+	return &ret
+}
+
+func (c *FSConfig) WithWorkDirFS(fs fs.FS) *FSConfig {
+	ret := *c // copy
+	ret.setFS(".", fs)
+	return &ret
+}
+
+func (c *FSConfig) Preopens() (map[uint32]*FileEntry, error) {
+	// Ensure no-one set a nil FD. We do this here instead of at the call site to allow chaining as nil is unexpected.
+	rootFD := uint32(0) // zero is invalid
+	setWorkDirFS := false
+	preopens := c.preopens
+	for fd, entry := range preopens {
+		if entry.FS == nil {
+			return nil, fmt.Errorf("FS for %s is nil", entry.Path)
+		} else if entry.Path == "/" {
+			rootFD = fd
+		} else if entry.Path == "." {
+			setWorkDirFS = true
+		}
+	}
+
+	// Default the working directory to the root FS if it exists.
+	if rootFD != 0 && !setWorkDirFS {
+		preopens[c.preopenFD] = &FileEntry{Path: ".", FS: preopens[rootFD].FS}
+	}
+
+	return preopens, nil
+}
diff --git a/internal/wasm/call_context.go b/internal/wasm/call_context.go
index 6bce5917..32cae816 100644
--- a/internal/wasm/call_context.go
+++ b/internal/wasm/call_context.go
@@ -90,7 +90,7 @@ func (m *CallContext) CloseWithExitCode(ctx context.Context, exitCode uint32) (e
 
 // close marks this CallContext as closed and releases underlying system resources without removing
 // from the store.
-func (m *CallContext) close(_ context.Context, exitCode uint32) (c bool, err error) {
+func (m *CallContext) close(ctx context.Context, exitCode uint32) (c bool, err error) {
 	// Note: If you use the context.Context param, don't forget to coerce nil to context.Background()!
 
 	closed := uint64(1) + uint64(exitCode)<<32 // Store exitCode as high-order bits.
@@ -98,7 +98,7 @@ func (m *CallContext) close(_ context.Context, exitCode uint32) (c bool, err err
 		return false, nil
 	}
 	if sys := m.Sys; sys != nil { // ex nil if from ModuleBuilder
-		return true, sys.Close()
+		return true, sys.FS().Close(ctx)
 	}
 	return true, nil
 }
diff --git a/internal/wasm/sys.go b/internal/wasm/sys.go
index 8c921502..20977ccb 100644
--- a/internal/wasm/sys.go
+++ b/internal/wasm/sys.go
@@ -5,20 +5,9 @@ import (
 	"errors"
 	"fmt"
 	"io"
-	"io/fs"
-	"math"
-	"sync/atomic"
-)
 
-// FileEntry maps a path to an open file in a file system.
-//
-// Note: This does not introduce cycles because the types here are in the package "wasi" not "internalwasi".
-type FileEntry struct {
-	Path string
-	FS   fs.FS
-	// File when nil this is a mount like "." or "/".
-	File fs.File
-}
+	"github.com/tetratelabs/wazero/internal/fs"
+)
 
 // SysContext holds module-scoped system resources currently only used by internalwasi.
 type SysContext struct {
@@ -28,22 +17,7 @@ type SysContext struct {
 	stdout, stderr        io.Writer
 	randSource            io.Reader
 
-	// openedFiles is a map of file descriptor numbers (>=3) to open files (or directories) and defaults to empty.
-	// TODO: This is unguarded, so not goroutine-safe!
-	openedFiles map[uint32]*FileEntry
-
-	// lastFD is not meant to be read directly. Rather by nextFD.
-	lastFD uint32
-}
-
-// nextFD gets the next file descriptor number in a goroutine safe way (monotonically) or zero if we ran out.
-// TODO: opendFiles is still not goroutine safe!
-// TODO: This can return zero if we ran out of file descriptors. A future change can optimize by re-using an FD pool.
-func (c *SysContext) nextFD() uint32 {
-	if c.lastFD == math.MaxUint32 {
-		return 0
-	}
-	return atomic.AddUint32(&c.lastFD, 1)
+	fs *fs.Context
 }
 
 // Args is like os.Args and defaults to nil.
@@ -98,6 +72,13 @@ func (c *SysContext) Stderr() io.Writer {
 	return c.stderr
 }
 
+func (c *SysContext) FS() *fs.Context {
+	if c.fs == nil {
+		return &fs.Context{}
+	}
+	return c.fs
+}
+
 // RandSource is a source of random bytes and defaults to crypto/rand.Reader.
 // see wazero.ModuleConfig WithRandSource
 func (c *SysContext) RandSource() io.Reader {
@@ -129,7 +110,7 @@ var _ = DefaultSysContext() // Force panic on bug.
 
 // NewSysContext is a factory function which helps avoid needing to know defaults or exporting all fields.
 // Note: max is exposed for testing. max is only used for env/args validation.
-func NewSysContext(max uint32, args, environ []string, stdin io.Reader, stdout, stderr io.Writer, randSource io.Reader, openedFiles map[uint32]*FileEntry) (sys *SysContext, err error) {
+func NewSysContext(max uint32, args, environ []string, stdin io.Reader, stdout, stderr io.Writer, randSource io.Reader, openedFiles map[uint32]*fs.FileEntry) (sys *SysContext, err error) {
 	sys = &SysContext{args: args, environ: environ}
 
 	if sys.argsSize, err = nullTerminatedByteCount(max, args); err != nil {
@@ -164,18 +145,8 @@ func NewSysContext(max uint32, args, environ []string, stdin io.Reader, stdout,
 		sys.randSource = randSource
 	}
 
-	if openedFiles == nil {
-		sys.openedFiles = map[uint32]*FileEntry{}
-		sys.lastFD = 2 // STDERR
-	} else {
-		sys.openedFiles = openedFiles
-		sys.lastFD = 2 // STDERR
-		for fd := range openedFiles {
-			if fd > sys.lastFD {
-				sys.lastFD = fd
-			}
-		}
-	}
+	sys.fs = fs.NewContext(openedFiles)
+
 	return
 }
 
@@ -207,50 +178,3 @@ func nullTerminatedByteCount(max uint32, elements []string) (uint32, error) {
 	}
 	return uint32(bufSize), nil
 }
-
-// Close implements io.Closer
-func (c *SysContext) Close() (err error) {
-	// Close any files opened in this context
-	for fd, entry := range c.openedFiles {
-		delete(c.openedFiles, fd)
-		if entry.File != nil { // File is nil for a mount like "." or "/"
-			if e := entry.File.Close(); e != nil {
-				err = e // This means the err returned == the last non-nil error.
-			}
-		}
-	}
-	return
-}
-
-// CloseFile returns true if a file was opened and closed without error, or false if not.
-func (c *SysContext) CloseFile(fd uint32) (bool, error) {
-	f, ok := c.openedFiles[fd]
-	if !ok {
-		return false, nil
-	}
-	delete(c.openedFiles, fd)
-
-	if f.File == nil { // TODO: currently, this means it is a pre-opened filesystem, but this may change later.
-		return true, nil
-	}
-	if err := f.File.Close(); err != nil {
-		return false, err
-	}
-	return true, nil
-}
-
-// OpenedFile returns a file and true if it was opened or nil and false, if not.
-func (c *SysContext) OpenedFile(fd uint32) (*FileEntry, bool) {
-	f, ok := c.openedFiles[fd]
-	return f, ok
-}
-
-// OpenFile returns the file descriptor of the new file or false if we ran out of file descriptors
-func (c *SysContext) OpenFile(f *FileEntry) (uint32, bool) {
-	newFD := c.nextFD()
-	if newFD == 0 {
-		return 0, false
-	}
-	c.openedFiles[newFD] = f
-	return newFD, true
-}
diff --git a/wasi/wasi.go b/wasi/wasi.go
index 0c758c7e..a6a8cd0f 100644
--- a/wasi/wasi.go
+++ b/wasi/wasi.go
@@ -16,6 +16,7 @@ import (
 	"github.com/tetratelabs/wazero"
 	"github.com/tetratelabs/wazero/api"
 	"github.com/tetratelabs/wazero/experimental"
+	internalfs "github.com/tetratelabs/wazero/internal/fs"
 	"github.com/tetratelabs/wazero/internal/wasm"
 )
 
@@ -691,9 +692,9 @@ func (a *snapshotPreview1) FdAllocate(ctx context.Context, m api.Module, fd uint
 // See https://github.com/WebAssembly/WASI/blob/main/phases/snapshot/docs.md#fd_close
 // See https://linux.die.net/man/3/close
 func (a *snapshotPreview1) FdClose(ctx context.Context, m api.Module, fd uint32) Errno {
-	sys := sysCtx(m)
+	_, fsc := sysFSCtx(ctx, m)
 
-	if ok, err := sys.CloseFile(fd); err != nil {
+	if ok, err := fsc.CloseFile(fd); err != nil {
 		return ErrnoIo
 	} else if !ok {
 		return ErrnoBadf
@@ -740,9 +741,9 @@ func (a *snapshotPreview1) FdDatasync(ctx context.Context, m api.Module, fd uint
 // See https://github.com/WebAssembly/WASI/blob/main/phases/snapshot/docs.md#fd_fdstat_get
 // See https://linux.die.net/man/3/fsync
 func (a *snapshotPreview1) FdFdstatGet(ctx context.Context, m api.Module, fd uint32, resultStat uint32) Errno {
-	sys := sysCtx(m)
+	_, fsc := sysFSCtx(ctx, m)
 
-	if _, ok := sys.OpenedFile(fd); !ok {
+	if _, ok := fsc.OpenedFile(fd); !ok {
 		return ErrnoBadf
 	}
 	return ErrnoSuccess
@@ -776,9 +777,9 @@ func (a *snapshotPreview1) FdFdstatGet(ctx context.Context, m api.Module, fd uin
 // See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#prestat
 // See https://github.com/WebAssembly/WASI/blob/main/phases/snapshot/docs.md#fd_prestat_get
 func (a *snapshotPreview1) FdPrestatGet(ctx context.Context, m api.Module, fd uint32, resultPrestat uint32) Errno {
-	sys := sysCtx(m)
+	_, fsc := sysFSCtx(ctx, m)
 
-	entry, ok := sys.OpenedFile(fd)
+	entry, ok := fsc.OpenedFile(fd)
 	if !ok {
 		return ErrnoBadf
 	}
@@ -851,9 +852,9 @@ func (a *snapshotPreview1) FdPread(ctx context.Context, m api.Module, fd, iovs,
 // See FdPrestatGet
 // See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#fd_prestat_dir_name
 func (a *snapshotPreview1) FdPrestatDirName(ctx context.Context, m api.Module, fd uint32, pathPtr uint32, pathLen uint32) Errno {
-	sys := sysCtx(m)
+	_, fsc := sysFSCtx(ctx, m)
 
-	f, ok := sys.OpenedFile(fd)
+	f, ok := fsc.OpenedFile(fd)
 	if !ok {
 		return ErrnoBadf
 	}
@@ -919,13 +920,13 @@ func (a *snapshotPreview1) FdPwrite(ctx context.Context, m api.Module, fd, iovs,
 // See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#iovec
 // See https://linux.die.net/man/3/readv
 func (a *snapshotPreview1) FdRead(ctx context.Context, m api.Module, fd, iovs, iovsCount, resultSize uint32) Errno {
-	sys := sysCtx(m)
+	sys, fsc := sysFSCtx(ctx, m)
 
 	var reader io.Reader
 
 	if fd == fdStdin {
 		reader = sys.Stdin()
-	} else if f, ok := sys.OpenedFile(fd); !ok || f.File == nil {
+	} else if f, ok := fsc.OpenedFile(fd); !ok || f.File == nil {
 		return ErrnoBadf
 	} else {
 		reader = f.File
@@ -1002,11 +1003,11 @@ func (a *snapshotPreview1) FdRenumber(ctx context.Context, m api.Module, fd, to
 // See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#fd_seek
 // See https://linux.die.net/man/3/lseek
 func (a *snapshotPreview1) FdSeek(ctx context.Context, m api.Module, fd uint32, offset uint64, whence uint32, resultNewoffset uint32) Errno {
-	sys := sysCtx(m)
+	_, fsc := sysFSCtx(ctx, m)
 
 	var seeker io.Seeker
 	// Check to see if the file descriptor is available
-	if f, ok := sys.OpenedFile(fd); !ok || f.File == nil {
+	if f, ok := fsc.OpenedFile(fd); !ok || f.File == nil {
 		return ErrnoBadf
 		// fs.FS doesn't declare io.Seeker, but implementations such as os.File implement it.
 	} else if seeker, ok = f.File.(io.Seeker); !ok {
@@ -1088,7 +1089,7 @@ func (a *snapshotPreview1) FdTell(ctx context.Context, m api.Module, fd, resultO
 // See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#fd_write
 // See https://linux.die.net/man/3/writev
 func (a *snapshotPreview1) FdWrite(ctx context.Context, m api.Module, fd, iovs, iovsCount, resultSize uint32) Errno {
-	sys := sysCtx(m)
+	sys, fsc := sysFSCtx(ctx, m)
 
 	var writer io.Writer
 
@@ -1099,7 +1100,7 @@ func (a *snapshotPreview1) FdWrite(ctx context.Context, m api.Module, fd, iovs,
 		writer = sys.Stderr()
 	default:
 		// Check to see if the file descriptor is available
-		if f, ok := sys.OpenedFile(fd); !ok || f.File == nil {
+		if f, ok := fsc.OpenedFile(fd); !ok || f.File == nil {
 			return ErrnoBadf
 			// fs.FS doesn't declare io.Writer, but implementations such as os.File implement it.
 		} else if writer, ok = f.File.(io.Writer); !ok {
@@ -1201,9 +1202,9 @@ func (a *snapshotPreview1) PathLink(ctx context.Context, m api.Module, oldFd, ol
 // See https://linux.die.net/man/3/openat
 func (a *snapshotPreview1) PathOpen(ctx context.Context, m api.Module, fd, dirflags, pathPtr, pathLen, oflags uint32, fsRightsBase,
 	fsRightsInheriting uint64, fdflags, resultOpenedFd uint32) (errno Errno) {
-	sys := sysCtx(m)
+	_, fsc := sysFSCtx(ctx, m)
 
-	dir, ok := sys.OpenedFile(fd)
+	dir, ok := fsc.OpenedFile(fd)
 	if !ok || dir.FS == nil {
 		return ErrnoBadf
 	}
@@ -1221,7 +1222,7 @@ func (a *snapshotPreview1) PathOpen(ctx context.Context, m api.Module, fd, dirfl
 		return errno
 	}
 
-	if newFD, ok := sys.OpenFile(entry); !ok {
+	if newFD, ok := fsc.OpenFile(entry); !ok {
 		_ = entry.File.Close()
 		return ErrnoIo
 	} else if !m.Memory().WriteUint32Le(ctx, resultOpenedFd, newFD) {
@@ -1362,7 +1363,23 @@ func sysCtx(m api.Module) *wasm.SysContext {
 	}
 }
 
-func openFileEntry(rootFS fs.FS, pathName string) (*wasm.FileEntry, Errno) {
+func sysFSCtx(ctx context.Context, m api.Module) (*wasm.SysContext, *internalfs.Context) {
+	if internal, ok := m.(*wasm.CallContext); !ok {
+		panic(fmt.Errorf("unsupported wasm.Module implementation: %v", m))
+	} else {
+		// Override Context when it is passed via context
+		if fsValue := ctx.Value(internalfs.Key{}); fsValue != nil {
+			fsCtx, ok := fsValue.(*internalfs.Context)
+			if !ok {
+				panic(fmt.Errorf("unsupported fs key: %v", fsValue))
+			}
+			return internal.Sys, fsCtx
+		}
+		return internal.Sys, internal.Sys.FS()
+	}
+}
+
+func openFileEntry(rootFS fs.FS, pathName string) (*internalfs.FileEntry, Errno) {
 	f, err := rootFS.Open(pathName)
 	if err != nil {
 		switch {
@@ -1378,7 +1395,7 @@ func openFileEntry(rootFS fs.FS, pathName string) (*wasm.FileEntry, Errno) {
 	// TODO: verify if oflags is a directory and fail with wasi.ErrnoNotdir if not
 	// See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#-oflags-flagsu16
 
-	return &wasm.FileEntry{Path: pathName, FS: rootFS, File: f}, ErrnoSuccess
+	return &internalfs.FileEntry{Path: pathName, FS: rootFS, File: f}, ErrnoSuccess
 }
 
 func writeOffsetsAndNullTerminatedValues(ctx context.Context, mem api.Memory, values []string, offsets, bytes uint32) Errno {


Test patch:
diff --git a/config_test.go b/config_test.go
index 7a0c9bcf..8d4600b6 100644
--- a/config_test.go
+++ b/config_test.go
@@ -9,6 +9,7 @@ import (
 	"testing/fstest"
 
 	"github.com/tetratelabs/wazero/api"
+	"github.com/tetratelabs/wazero/internal/fs"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 	"github.com/tetratelabs/wazero/internal/wasm"
 )
@@ -438,7 +439,7 @@ func TestModuleConfig_toSysContext(t *testing.T) {
 				nil,            // stdout
 				nil,            // stderr
 				nil,            // randSource
-				map[uint32]*wasm.FileEntry{ // openedFiles
+				map[uint32]*fs.FileEntry{ // openedFiles
 					3: {Path: "/", FS: testFS},
 					4: {Path: ".", FS: testFS},
 				},
@@ -455,7 +456,7 @@ func TestModuleConfig_toSysContext(t *testing.T) {
 				nil,            // stdout
 				nil,            // stderr
 				nil,            // randSource
-				map[uint32]*wasm.FileEntry{ // openedFiles
+				map[uint32]*fs.FileEntry{ // openedFiles
 					3: {Path: "/", FS: testFS2},
 					4: {Path: ".", FS: testFS2},
 				},
@@ -472,7 +473,7 @@ func TestModuleConfig_toSysContext(t *testing.T) {
 				nil,            // stdout
 				nil,            // stderr
 				nil,            // randSource
-				map[uint32]*wasm.FileEntry{ // openedFiles
+				map[uint32]*fs.FileEntry{ // openedFiles
 					3: {Path: ".", FS: testFS},
 				},
 			),
@@ -488,7 +489,7 @@ func TestModuleConfig_toSysContext(t *testing.T) {
 				nil,            // stdout
 				nil,            // stderr
 				nil,            // randSource
-				map[uint32]*wasm.FileEntry{ // openedFiles
+				map[uint32]*fs.FileEntry{ // openedFiles
 					3: {Path: "/", FS: testFS},
 					4: {Path: ".", FS: testFS2},
 				},
@@ -505,7 +506,7 @@ func TestModuleConfig_toSysContext(t *testing.T) {
 				nil,            // stdout
 				nil,            // stderr
 				nil,            // randSource
-				map[uint32]*wasm.FileEntry{ // openedFiles
+				map[uint32]*fs.FileEntry{ // openedFiles
 					3: {Path: ".", FS: testFS},
 					4: {Path: "/", FS: testFS2},
 				},
@@ -576,7 +577,7 @@ func TestModuleConfig_toSysContext_Errors(t *testing.T) {
 }
 
 // requireSysContext ensures wasm.NewSysContext doesn't return an error, which makes it usable in test matrices.
-func requireSysContext(t *testing.T, max uint32, args, environ []string, stdin io.Reader, stdout, stderr io.Writer, randsource io.Reader, openedFiles map[uint32]*wasm.FileEntry) *wasm.SysContext {
+func requireSysContext(t *testing.T, max uint32, args, environ []string, stdin io.Reader, stdout, stderr io.Writer, randsource io.Reader, openedFiles map[uint32]*fs.FileEntry) *wasm.SysContext {
 	sys, err := wasm.NewSysContext(max, args, environ, stdin, stdout, stderr, randsource, openedFiles)
 	require.NoError(t, err)
 	return sys
diff --git a/examples/wasi/testdata/sub/test.txt b/examples/wasi/testdata/sub/test.txt
new file mode 100644
index 00000000..4b45af5e
--- /dev/null
+++ b/examples/wasi/testdata/sub/test.txt
@@ -0,0 +1,1 @@
+greet sub dir
diff --git a/experimental/fs_test.go b/experimental/fs_test.go
new file mode 100644
index 00000000..abdef6ae
--- /dev/null
+++ b/experimental/fs_test.go
@@ -0,0 +1,41 @@
+package experimental_test
+
+import (
+	"context"
+	_ "embed"
+	"log"
+	"testing"
+	"testing/fstest"
+
+	"github.com/tetratelabs/wazero/experimental"
+	"github.com/tetratelabs/wazero/internal/fs"
+	"github.com/tetratelabs/wazero/internal/testing/require"
+)
+
+// This is a very basic integration of fs config. The main goal is to show how it is configured.
+func TestWithFS(t *testing.T) {
+	fileName := "animals.txt"
+	mapfs := fstest.MapFS{fileName: &fstest.MapFile{Data: []byte(`animals`)}}
+
+	// Set context to one that has experimental fs config
+	ctx, closer, err := experimental.WithFS(context.Background(), mapfs)
+	if err != nil {
+		log.Panicln(err)
+	}
+	defer closer.Close(ctx)
+
+	v := ctx.Value(fs.Key{})
+	require.NotNil(t, v)
+	fsCtx, ok := v.(*fs.Context)
+	require.True(t, ok)
+
+	entry, ok := fsCtx.OpenedFile(3)
+	require.True(t, ok)
+	require.Equal(t, "/", entry.Path)
+	require.Equal(t, mapfs, entry.FS)
+
+	entry, ok = fsCtx.OpenedFile(4)
+	require.True(t, ok)
+	require.Equal(t, ".", entry.Path)
+	require.Equal(t, mapfs, entry.FS)
+}
diff --git a/internal/fs/fs_test.go b/internal/fs/fs_test.go
new file mode 100644
index 00000000..4f367dd4
--- /dev/null
+++ b/internal/fs/fs_test.go
@@ -0,0 +1,49 @@
+package fs
+
+import (
+	"context"
+	"io/fs"
+	"os"
+	"path"
+	"testing"
+
+	"github.com/tetratelabs/wazero/internal/testing/require"
+)
+
+// testCtx is an arbitrary, non-default context. Non-nil also prevents linter errors.
+var testCtx = context.WithValue(context.Background(), struct{}{}, "arbitrary")
+
+func TestContext_Close(t *testing.T) {
+	tempDir := t.TempDir()
+	pathName := "test"
+	file, _ := createWriteableFile(t, tempDir, pathName, make([]byte, 0))
+
+	fsc := NewContext(map[uint32]*FileEntry{
+		3: {Path: "."},
+		4: {Path: path.Join(".", pathName), File: file},
+	})
+
+	// Verify base case
+	require.True(t, len(fsc.openedFiles) > 0, "fsc.openedFiles was empty")
+
+	// Closing should not err.
+	require.NoError(t, fsc.Close(testCtx))
+
+	// Verify our intended side-effect
+	require.Equal(t, 0, len(fsc.openedFiles), "expected no opened files")
+
+	// Verify no error closing again.
+	require.NoError(t, fsc.Close(testCtx))
+}
+
+// createWriteableFile uses real files when io.Writer tests are needed.
+func createWriteableFile(t *testing.T, tmpDir string, pathName string, data []byte) (fs.File, fs.FS) {
+	require.NotNil(t, data)
+	absolutePath := path.Join(tmpDir, pathName)
+	require.NoError(t, os.WriteFile(absolutePath, data, 0o600))
+
+	// open the file for writing in a custom way until #390
+	f, err := os.OpenFile(absolutePath, os.O_RDWR, 0o600)
+	require.NoError(t, err)
+	return f, os.DirFS(tmpDir)
+}
diff --git a/internal/wasm/call_context_test.go b/internal/wasm/call_context_test.go
index 055453a9..71305924 100644
--- a/internal/wasm/call_context_test.go
+++ b/internal/wasm/call_context_test.go
@@ -3,9 +3,9 @@ package wasm
 import (
 	"context"
 	"fmt"
-	"path"
 	"testing"
 
+	"github.com/tetratelabs/wazero/internal/fs"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
@@ -141,10 +141,6 @@ func TestCallContext_Close(t *testing.T) {
 	}
 
 	t.Run("calls SysContext.Close()", func(t *testing.T) {
-		tempDir := t.TempDir()
-		pathName := "test"
-		file, _ := createWriteableFile(t, tempDir, pathName, make([]byte, 0))
-
 		sys, err := NewSysContext(
 			0,   // max
 			nil, // args
@@ -153,26 +149,29 @@ func TestCallContext_Close(t *testing.T) {
 			nil, // stdout
 			nil, // stderr
 			nil, // randSource
-			map[uint32]*FileEntry{ // openedFiles
+			map[uint32]*fs.FileEntry{ // openedFiles
 				3: {Path: "."},
-				4: {Path: path.Join(".", pathName), File: file},
 			},
 		)
 		require.NoError(t, err)
 
+		fsCtx := sys.FS()
+
 		moduleName := t.Name()
 		m, err := s.Instantiate(context.Background(), &Module{}, moduleName, sys, nil)
 		require.NoError(t, err)
 
 		// We use side effects to determine if Close in fact called SysContext.Close (without repeating sys_test.go).
 		// One side effect of SysContext.Close is that it clears the openedFiles map. Verify our base case.
-		require.True(t, len(sys.openedFiles) > 0, "sys.openedFiles was empty")
+		_, ok := fsCtx.OpenedFile(3)
+		require.True(t, ok, "sys.openedFiles was empty")
 
 		// Closing should not err.
 		require.NoError(t, m.Close(testCtx))
 
 		// Verify our intended side-effect
-		require.Equal(t, 0, len(sys.openedFiles), "expected no opened files")
+		_, ok = fsCtx.OpenedFile(3)
+		require.False(t, ok, "expected no opened files")
 
 		// Verify no error closing again.
 		require.NoError(t, m.Close(testCtx))
diff --git a/internal/wasm/sys_test.go b/internal/wasm/sys_test.go
index 3de8399a..52b08953 100644
--- a/internal/wasm/sys_test.go
+++ b/internal/wasm/sys_test.go
@@ -3,11 +3,7 @@ package wasm
 import (
 	"bytes"
 	"io"
-	"io/fs"
-	"os"
-	"path"
 	"testing"
-	"testing/fstest"
 
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
@@ -32,8 +28,6 @@ func TestDefaultSysContext(t *testing.T) {
 	require.Equal(t, eofReader{}, sys.Stdin())
 	require.Equal(t, io.Discard, sys.Stdout())
 	require.Equal(t, io.Discard, sys.Stderr())
-	require.Equal(t, 0, len(sys.openedFiles), "expected no opened files")
-
 	require.Equal(t, sys, DefaultSysContext())
 }
 
@@ -154,108 +148,3 @@ func TestNewSysContext_Environ(t *testing.T) {
 		})
 	}
 }
-
-func TestSysContext_Close(t *testing.T) {
-	t.Run("no files", func(t *testing.T) {
-		sys := DefaultSysContext()
-		require.NoError(t, sys.Close())
-	})
-
-	t.Run("open files", func(t *testing.T) {
-		tempDir := t.TempDir()
-		pathName := "test"
-		file, testFS := createWriteableFile(t, tempDir, pathName, make([]byte, 0))
-
-		sys, err := NewSysContext(
-			0,   // max
-			nil, // args
-			nil, // environ
-			nil, // stdin
-			nil, // stdout
-			nil, // stderr
-			nil, // randSource
-			map[uint32]*FileEntry{ // openedFiles
-				3: {Path: "/", FS: testFS},
-				4: {Path: ".", FS: testFS},
-				5: {Path: path.Join(".", pathName), File: file, FS: testFS},
-			},
-		)
-		require.NoError(t, err)
-
-		// Closing should delete the file descriptors after closing the files.
-		require.NoError(t, sys.Close())
-		require.Equal(t, 0, len(sys.openedFiles), "expected no opened files")
-
-		// Verify it was actually closed, by trying to close it again.
-		err = file.(*os.File).Close()
-		require.Contains(t, err.Error(), "file already closed")
-
-		// No problem closing config again because the descriptors were removed, so they won't be called again.
-		require.NoError(t, sys.Close())
-	})
-
-	t.Run("FS never used", func(t *testing.T) {
-		testFS := fstest.MapFS{}
-		sys, err := NewSysContext(
-			0,   // max
-			nil, // args
-			nil, // environ
-			nil, // stdin
-			nil, // stdout
-			nil, // stderr
-			nil, // randSource
-			map[uint32]*FileEntry{ // no openedFiles
-				3: {Path: "/", FS: testFS},
-				4: {Path: ".", FS: testFS},
-			},
-		)
-		require.NoError(t, err)
-
-		// Even if there are no open files, the descriptors for the file-system mappings should be removed.
-		require.NoError(t, sys.Close())
-		require.Equal(t, 0, len(sys.openedFiles), "expected no opened files")
-	})
-
-	t.Run("open file externally closed", func(t *testing.T) {
-		tempDir := t.TempDir()
-		pathName := "test"
-		file, testFS := createWriteableFile(t, tempDir, pathName, make([]byte, 0))
-
-		sys, err := NewSysContext(
-			0,   // max
-			nil, // args
-			nil, // environ
-			nil, // stdin
-			nil, // stdout
-			nil, // stderr
-			nil, // randSource
-			map[uint32]*FileEntry{ // openedFiles
-				3: {Path: "/", FS: testFS},
-				4: {Path: ".", FS: testFS},
-				5: {Path: path.Join(".", pathName), File: file, FS: testFS},
-			},
-		)
-		require.NoError(t, err)
-
-		// Close the file externally
-		file.Close()
-
-		// Closing should err as it expected to be open
-		require.Contains(t, sys.Close().Error(), "file already closed")
-
-		// However, cleanup should still occur.
-		require.Equal(t, 0, len(sys.openedFiles), "expected no opened files")
-	})
-}
-
-// createWriteableFile uses real files when io.Writer tests are needed.
-func createWriteableFile(t *testing.T, tmpDir string, pathName string, data []byte) (fs.File, fs.FS) {
-	require.NotNil(t, data)
-	absolutePath := path.Join(tmpDir, pathName)
-	require.NoError(t, os.WriteFile(absolutePath, data, 0o600))
-
-	// open the file for writing in a custom way until #390
-	f, err := os.OpenFile(absolutePath, os.O_RDWR, 0o600)
-	require.NoError(t, err)
-	return f, os.DirFS(tmpDir)
-}
diff --git a/wasi/wasi_test.go b/wasi/wasi_test.go
index 88cedc45..2f92a7e4 100644
--- a/wasi/wasi_test.go
+++ b/wasi/wasi_test.go
@@ -19,6 +19,7 @@ import (
 	"github.com/tetratelabs/wazero"
 	"github.com/tetratelabs/wazero/api"
 	"github.com/tetratelabs/wazero/experimental"
+	fs2 "github.com/tetratelabs/wazero/internal/fs"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 	"github.com/tetratelabs/wazero/internal/wasm"
 	"github.com/tetratelabs/wazero/sys"
@@ -546,7 +547,7 @@ func TestSnapshotPreview1_FdClose(t *testing.T) {
 		entry2, errno := openFileEntry(testFs, path2)
 		require.Zero(t, errno, ErrnoName(errno))
 
-		sysCtx, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
+		sysCtx, err := newSysContext(nil, nil, map[uint32]*fs2.FileEntry{
 			fdToClose: entry1,
 			fdToKeep:  entry2,
 		})
@@ -558,11 +559,12 @@ func TestSnapshotPreview1_FdClose(t *testing.T) {
 
 	verify := func(mod api.Module) {
 		// Verify fdToClose is closed and removed from the opened FDs.
-		_, ok := sysCtx(mod).OpenedFile(fdToClose)
+		_, fsc := sysFSCtx(testCtx, mod)
+		_, ok := fsc.OpenedFile(fdToClose)
 		require.False(t, ok)
 
 		// Verify fdToKeep is not closed
-		_, ok = sysCtx(mod).OpenedFile(fdToKeep)
+		_, ok = fsc.OpenedFile(fdToKeep)
 		require.True(t, ok)
 	}
 
@@ -731,7 +733,7 @@ func TestSnapshotPreview1_FdPrestatGet(t *testing.T) {
 	fd := uint32(3) // arbitrary fd after 0, 1, and 2, that are stdin/out/err
 
 	pathName := "/tmp"
-	sysCtx, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{fd: {Path: pathName}})
+	sysCtx, err := newSysContext(nil, nil, map[uint32]*fs2.FileEntry{fd: {Path: pathName}})
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(testCtx, t, functionFdPrestatGet, importFdPrestatGet, sysCtx)
@@ -776,7 +778,7 @@ func TestSnapshotPreview1_FdPrestatGet_Errors(t *testing.T) {
 	fd := uint32(3)           // fd 3 will be opened for the "/tmp" directory after 0, 1, and 2, that are stdin/out/err
 	validAddress := uint32(0) // Arbitrary valid address as arguments to fd_prestat_get. We chose 0 here.
 
-	sysCtx, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{fd: {Path: "/tmp"}})
+	sysCtx, err := newSysContext(nil, nil, map[uint32]*fs2.FileEntry{fd: {Path: "/tmp"}})
 	require.NoError(t, err)
 
 	a, mod, _ := instantiateModule(testCtx, t, functionFdPrestatGet, importFdPrestatGet, sysCtx)
@@ -818,7 +820,7 @@ func TestSnapshotPreview1_FdPrestatGet_Errors(t *testing.T) {
 func TestSnapshotPreview1_FdPrestatDirName(t *testing.T) {
 	fd := uint32(3) // arbitrary fd after 0, 1, and 2, that are stdin/out/err
 
-	sysCtx, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{fd: {Path: "/tmp"}})
+	sysCtx, err := newSysContext(nil, nil, map[uint32]*fs2.FileEntry{fd: {Path: "/tmp"}})
 	require.NoError(t, err)
 
 	a, mod, fn := instantiateModule(testCtx, t, functionFdPrestatDirName, importFdPrestatDirName, sysCtx)
@@ -859,7 +861,7 @@ func TestSnapshotPreview1_FdPrestatDirName(t *testing.T) {
 
 func TestSnapshotPreview1_FdPrestatDirName_Errors(t *testing.T) {
 	fd := uint32(3) // arbitrary fd after 0, 1, and 2, that are stdin/out/err
-	sysCtx, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{fd: {Path: "/tmp"}})
+	sysCtx, err := newSysContext(nil, nil, map[uint32]*fs2.FileEntry{fd: {Path: "/tmp"}})
 	require.NoError(t, err)
 
 	a, mod, _ := instantiateModule(testCtx, t, functionFdPrestatDirName, importFdPrestatDirName, sysCtx)
@@ -981,7 +983,7 @@ func TestSnapshotPreview1_FdRead(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			// Create a fresh file to read the contents from
 			file, testFS := createFile(t, "test_path", []byte("wazero"))
-			sysCtx, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
+			sysCtx, err := newSysContext(nil, nil, map[uint32]*fs2.FileEntry{
 				fd: {Path: "test_path", FS: testFS, File: file},
 			})
 			require.NoError(t, err)
@@ -1008,7 +1010,7 @@ func TestSnapshotPreview1_FdRead_Errors(t *testing.T) {
 	validFD := uint32(3)                                 // arbitrary valid fd after 0, 1, and 2, that are stdin/out/err
 	file, testFS := createFile(t, "test_path", []byte{}) // file with empty contents
 
-	sysCtx, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
+	sysCtx, err := newSysContext(nil, nil, map[uint32]*fs2.FileEntry{
 		validFD: {Path: "test_path", FS: testFS, File: file},
 	})
 	require.NoError(t, err)
@@ -1137,11 +1139,13 @@ func TestSnapshotPreview1_FdSeek(t *testing.T) {
 	resultNewoffset := uint32(1)                                 // arbitrary offset in `ctx.Memory` for the new offset value
 	file, testFS := createFile(t, "test_path", []byte("wazero")) // arbitrary non-empty contents
 
-	sysCtx, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
+	sysCtx, err := newSysContext(nil, nil, map[uint32]*fs2.FileEntry{
 		fd: {Path: "test_path", FS: testFS, File: file},
 	})
 	require.NoError(t, err)
 
+	fsCtx := sysCtx.FS()
+
 	a, mod, fn := instantiateModule(testCtx, t, functionFdSeek, importFdSeek, sysCtx)
 	defer mod.Close(testCtx)
 
@@ -1214,7 +1218,7 @@ func TestSnapshotPreview1_FdSeek(t *testing.T) {
 					maskMemory(t, testCtx, mod, len(tc.expectedMemory))
 
 					// Since we initialized this file, we know it is a seeker (because it is a MapFile)
-					f, ok := sysCtx.OpenedFile(fd)
+					f, ok := fsCtx.OpenedFile(fd)
 					require.True(t, ok)
 					seeker := f.File.(io.Seeker)
 
@@ -1243,7 +1247,7 @@ func TestSnapshotPreview1_FdSeek_Errors(t *testing.T) {
 	validFD := uint32(3)                                         // arbitrary valid fd after 0, 1, and 2, that are stdin/out/err
 	file, testFS := createFile(t, "test_path", []byte("wazero")) // arbitrary valid file with non-empty contents
 
-	sysCtx, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
+	sysCtx, err := newSysContext(nil, nil, map[uint32]*fs2.FileEntry{
 		validFD: {Path: "test_path", FS: testFS, File: file},
 	})
 	require.NoError(t, err)
@@ -1374,7 +1378,7 @@ func TestSnapshotPreview1_FdWrite(t *testing.T) {
 			// Create a fresh file to write the contents to
 			pathName := "test_path"
 			file, testFS := createWriteableFile(t, tmpDir, pathName, []byte{})
-			sysCtx, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
+			sysCtx, err := newSysContext(nil, nil, map[uint32]*fs2.FileEntry{
 				fd: {Path: pathName, FS: testFS, File: file},
 			})
 			require.NoError(t, err)
@@ -1409,7 +1413,7 @@ func TestSnapshotPreview1_FdWrite_Errors(t *testing.T) {
 	pathName := "test_path"
 	file, testFS := createWriteableFile(t, tmpDir, pathName, []byte{})
 
-	sysCtx, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
+	sysCtx, err := newSysContext(nil, nil, map[uint32]*fs2.FileEntry{
 		validFD: {Path: pathName, FS: testFS, File: file},
 	})
 	require.NoError(t, err)
@@ -1553,32 +1557,44 @@ func TestSnapshotPreview1_PathLink(t *testing.T) {
 }
 
 func TestSnapshotPreview1_PathOpen(t *testing.T) {
-	workdirFD := uint32(3) // arbitrary fd after 0, 1, and 2, that are stdin/out/err
-	dirflags := uint32(0)  // arbitrary dirflags
-	oflags := uint32(0)    // arbitrary oflags
-	fdFlags := uint32(0)
-
-	// Setup the initial memory to include the path name starting at an offset.
-	pathName := "wazero"
-	path := uint32(1)
-	pathLen := uint32(len(pathName))
-	initialMemory := append([]byte{'?'}, pathName...)
-
-	expectedFD := byte(workdirFD + 1)
-	resultOpenedFd := uint32(len(initialMemory) + 1)
-	expectedMemory := append(
-		initialMemory,
-		'?', // `resultOpenedFd` is after this
-		expectedFD, 0, 0, 0,
-		'?',
-	)
+	type pathOpenArgs struct {
+		fd                 uint32
+		dirflags           uint32
+		pathPtr            uint32
+		pathLen            uint32
+		oflags             uint32
+		fsRightsBase       uint64
+		fsRightsInheriting uint64
+		fdflags            uint32
+		resultOpenedFd     uint32
+	}
 
-	// rights are ignored per https://github.com/WebAssembly/WASI/issues/469#issuecomment-1045251844
-	fsRightsBase, fsRightsInheriting := uint64(1), uint64(2)
+	setup := func(workdirFD uint32, pathName string) (*snapshotPreview1, api.Module, api.Function, pathOpenArgs, []byte, uint32) {
+		// Setup the initial memory to include the path name starting at an offset.
+		initialMemory := append([]byte{'?'}, pathName...)
+
+		expectedFD := workdirFD + 1
+		expectedMemory := append(
+			initialMemory,
+			'?', // `resultOpenedFd` is after this
+			byte(expectedFD), 0, 0, 0,
+			'?',
+		)
+
+		args := pathOpenArgs{
+			fd:                 workdirFD,
+			dirflags:           0,
+			pathPtr:            1,
+			pathLen:            uint32(len(pathName)),
+			oflags:             0,
+			fsRightsBase:       1, // rights are ignored per https://github.com/WebAssembly/WASI/issues/469#issuecomment-1045251844
+			fsRightsInheriting: 2,
+			fdflags:            0,
+			resultOpenedFd:     uint32(len(initialMemory) + 1),
+		}
 
-	setup := func() (*snapshotPreview1, api.Module, api.Function) {
 		testFS := fstest.MapFS{pathName: &fstest.MapFile{Mode: os.ModeDir}}
-		sysCtx, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
+		sysCtx, err := newSysContext(nil, nil, map[uint32]*fs2.FileEntry{
 			workdirFD: {Path: ".", FS: testFS},
 		})
 		require.NoError(t, err)
@@ -1586,10 +1602,10 @@ func TestSnapshotPreview1_PathOpen(t *testing.T) {
 		maskMemory(t, testCtx, mod, len(expectedMemory))
 		ok := mod.Memory().Write(testCtx, 0, initialMemory)
 		require.True(t, ok)
-		return a, mod, fn
+		return a, mod, fn, args, expectedMemory, expectedFD
 	}
 
-	verify := func(errno Errno, mod api.Module) {
+	verify := func(ctx context.Context, errno Errno, mod api.Module, pathName string, expectedMemory []byte, expectedFD uint32) {
 		require.Zero(t, errno, ErrnoName(errno))
 
 		actual, ok := mod.Memory().Read(testCtx, 0, uint32(len(expectedMemory)))
@@ -1597,23 +1613,55 @@ func TestSnapshotPreview1_PathOpen(t *testing.T) {
 		require.Equal(t, expectedMemory, actual)
 
 		// verify the file was actually opened
-		f, ok := sysCtx(mod).OpenedFile(uint32(expectedFD))
+		_, fsc := sysFSCtx(ctx, mod)
+		f, ok := fsc.OpenedFile(expectedFD)
 		require.True(t, ok)
 		require.Equal(t, pathName, f.Path)
 	}
 
 	t.Run("snapshotPreview1.PathOpen", func(t *testing.T) {
-		a, mod, _ := setup()
-		errno := a.PathOpen(testCtx, mod, workdirFD, dirflags, path, pathLen, oflags, fsRightsBase, fsRightsInheriting, fdFlags, resultOpenedFd)
-		verify(errno, mod)
+		workdirFD := uint32(3) // arbitrary fd after 0, 1, and 2, that are stdin/out/err
+		pathName := "wazero"
+
+		a, mod, _, args, expectedMemory, expectedFD := setup(workdirFD, pathName)
+		errno := a.PathOpen(testCtx, mod, args.fd, args.dirflags, args.pathPtr, args.pathLen, args.oflags,
+			args.fsRightsBase, args.fsRightsInheriting, args.fdflags, args.resultOpenedFd)
+		verify(testCtx, errno, mod, pathName, expectedMemory, expectedFD)
 	})
 
 	t.Run(functionPathOpen, func(t *testing.T) {
-		_, mod, fn := setup()
-		results, err := fn.Call(testCtx, uint64(workdirFD), uint64(dirflags), uint64(path), uint64(pathLen), uint64(oflags), fsRightsBase, fsRightsInheriting, uint64(fdFlags), uint64(resultOpenedFd))
+		workdirFD := uint32(3) // arbitrary fd after 0, 1, and 2, that are stdin/out/err
+		pathName := "wazero"
+
+		_, mod, fn, args, expectedMemory, expectedFD := setup(workdirFD, pathName)
+		results, err := fn.Call(testCtx, uint64(args.fd), uint64(args.dirflags), uint64(args.pathPtr), uint64(args.pathLen),
+			uint64(args.oflags), args.fsRightsBase, args.fsRightsInheriting, uint64(args.fdflags), uint64(args.resultOpenedFd))
 		require.NoError(t, err)
 		errno := Errno(results[0])
-		verify(errno, mod)
+		verify(testCtx, errno, mod, pathName, expectedMemory, expectedFD)
+	})
+
+	t.Run("snapshotPreview1.PathOpen.WithFS", func(t *testing.T) {
+		workdirFD := uint32(100) // dummy fd as it is not used
+		pathName := "wazero"
+
+		// The filesystem initialized in setup() is not used as it will be overridden.
+		a, mod, _, args, expectedMemory, _ := setup(workdirFD, pathName)
+
+		// Override fs.FS through context
+		workdirFD = uint32(4) // 3 is '/' and 4 is '.'
+		expectedFD := workdirFD + 1
+		expectedMemory[8] = byte(expectedFD) // replace expected memory with expected fd
+		testFS := fstest.MapFS{pathName: &fstest.MapFile{Mode: os.ModeDir}}
+		ctx, closer, err := experimental.WithFS(testCtx, testFS)
+		require.NoError(t, err)
+		defer closer.Close(ctx)
+
+		errno := a.PathOpen(ctx, mod, workdirFD, args.dirflags, args.pathPtr, args.pathLen, args.oflags,
+			args.fsRightsBase, args.fsRightsInheriting, args.fdflags, args.resultOpenedFd)
+		require.Zero(t, errno, ErrnoName(errno))
+
+		verify(ctx, errno, mod, pathName, expectedMemory, expectedFD)
 	})
 }
 
@@ -1622,7 +1670,7 @@ func TestSnapshotPreview1_PathOpen_Errors(t *testing.T) {
 	pathName := "wazero"
 	testFS := fstest.MapFS{pathName: &fstest.MapFile{Mode: os.ModeDir}}
 
-	sysCtx, err := newSysContext(nil, nil, map[uint32]*wasm.FileEntry{
+	sysCtx, err := newSysContext(nil, nil, map[uint32]*fs2.FileEntry{
 		validFD: {Path: ".", FS: testFS},
 	})
 	require.NoError(t, err)
@@ -2076,7 +2124,7 @@ func instantiateModule(ctx context.Context, t *testing.T, wasifunction, wasiimpo
 	return a, mod, fn
 }
 
-func newSysContext(args, environ []string, openedFiles map[uint32]*wasm.FileEntry) (sysCtx *wasm.SysContext, err error) {
+func newSysContext(args, environ []string, openedFiles map[uint32]*fs2.FileEntry) (sysCtx *wasm.SysContext, err error) {
 	return wasm.NewSysContext(math.MaxUint32, args, environ, new(bytes.Buffer), nil, nil, nil, openedFiles)
 }