Repository: tetratelabs/wazero
Pull request #1245 (2023-03-15 12:29:51)

gojs: adds support for uid and gid
This adds `gojs.WithOSUser` which passes through current user IDs so
that GOOS=js compiled wasm can read them. This also adds support for
reading back the uid and gid on files. In summary, this passes
`os.TestChown` except on windows where it will not work due to lack of
support.


Patch:
diff --git a/cmd/wazero/wazero.go b/cmd/wazero/wazero.go
index d86eab52..4845cbf1 100644
--- a/cmd/wazero/wazero.go
+++ b/cmd/wazero/wazero.go
@@ -263,7 +263,7 @@ func doRun(args []string, stdOut io.Writer, stdErr logging.Writer, exit func(cod
 	case modeGo:
 		gojs.MustInstantiate(ctx, rt)
 
-		config := gojs.NewConfig(conf)
+		config := gojs.NewConfig(conf).WithOSUser()
 
 		// Strip the volume of the path, for example C:\
 		rootDir := rootPath[len(filepath.VolumeName(rootPath)):]
diff --git a/experimental/gojs/gojs.go b/experimental/gojs/gojs.go
index c5353dcf..67f85566 100644
--- a/experimental/gojs/gojs.go
+++ b/experimental/gojs/gojs.go
@@ -112,6 +112,17 @@ type Config interface {
 	// as the guest path "", while the current directory is inside `D:\`.
 	WithOSWorkdir() Config
 
+	// WithOSUser allows the guest to see the current user's uid, gid, euid and
+	// groups, instead of zero for each value.
+	//
+	// Here's an example that uses the real user's IDs:
+	//
+	//	err = gojs.Run(ctx, r, compiled, gojs.NewConfig(moduleConfig).
+	//			WithOSUser())
+	//
+	// Note: This has no effect on windows.
+	WithOSUser() Config
+
 	// WithRoundTripper sets the http.RoundTripper used to Run Wasm.
 	//
 	// For example, if the code compiled via `GOARCH=wasm GOOS=js` uses
@@ -144,6 +155,13 @@ func (c *cfg) WithOSWorkdir() Config {
 	return ret
 }
 
+// WithOSUser implements Config.WithOSUser
+func (c *cfg) WithOSUser() Config {
+	ret := c.clone()
+	ret.internal.OsUser = true
+	return ret
+}
+
 // WithRoundTripper implements Config.WithRoundTripper
 func (c *cfg) WithRoundTripper(rt http.RoundTripper) Config {
 	ret := c.clone()
diff --git a/internal/gojs/builtin.go b/internal/gojs/builtin.go
index 17cfbd37..e69e807f 100644
--- a/internal/gojs/builtin.go
+++ b/internal/gojs/builtin.go
@@ -1,18 +1,25 @@
 package gojs
 
 import (
-	"net/http"
-
-	"github.com/tetratelabs/wazero/internal/gojs/custom"
+	"github.com/tetratelabs/wazero/internal/gojs/config"
 	"github.com/tetratelabs/wazero/internal/gojs/goos"
 )
 
 // newJsGlobal = js.Global() // js.go init
-func newJsGlobal(rt http.RoundTripper) *jsVal {
+func newJsGlobal(config *config.Config) *jsVal {
 	var fetchProperty interface{} = goos.Undefined
-	if rt != nil {
+	uid, gid, euid := config.Uid, config.Gid, config.Euid
+	groups := config.Groups
+	proc := &processState{
+		cwd:   config.Workdir,
+		umask: config.Umask,
+	}
+	rt := config.Rt
+
+	if config.Rt != nil {
 		fetchProperty = goos.RefHttpFetch
 	}
+
 	return newJsVal(goos.RefValueGlobal, "global").
 		addProperties(map[string]interface{}{
 			"Object":          objectConstructor,
@@ -22,8 +29,8 @@ func newJsGlobal(rt http.RoundTripper) *jsVal {
 			"fetch":           fetchProperty,
 			"AbortController": goos.Undefined,
 			"Headers":         headersConstructor,
-			"process":         jsProcess,
-			"fs":              jsfs,
+			"process":         newJsProcess(uid, gid, euid, groups, proc),
+			"fs":              newJsFs(proc),
 			"Date":            jsDateConstructor,
 		}).
 		addFunction("fetch", &httpFetch{rt})
@@ -43,20 +50,6 @@ var (
 	//	Get("Array") // js.go init
 	arrayConstructor = newJsVal(goos.RefArrayConstructor, "Array")
 
-	// jsProcess = js.Global().Get("process") // fs_js.go init
-	jsProcess = newJsVal(goos.RefJsProcess, custom.NameProcess).
-			addProperties(map[string]interface{}{
-			"pid":  float64(1),        // Get("pid").Int() in syscall_js.go for syscall.Getpid
-			"ppid": goos.RefValueZero, // Get("ppid").Int() in syscall_js.go for syscall.Getppid
-		}).
-		addFunction(custom.NameProcessCwd, processCwd{}).              // syscall.Cwd in fs_js.go
-		addFunction(custom.NameProcessChdir, processChdir{}).          // syscall.Chdir in fs_js.go
-		addFunction(custom.NameProcessGetuid, returnZero{}).           // syscall.Getuid in syscall_js.go
-		addFunction(custom.NameProcessGetgid, returnZero{}).           // syscall.Getgid in syscall_js.go
-		addFunction(custom.NameProcessGeteuid, returnZero{}).          // syscall.Geteuid in syscall_js.go
-		addFunction(custom.NameProcessGetgroups, returnSliceOfZero{}). // syscall.Getgroups in syscall_js.go
-		addFunction(custom.NameProcessUmask, processUmask{})           // syscall.Umask in syscall_js.go
-
 	// uint8ArrayConstructor = js.Global().Get("Uint8Array")
 	//	// fs_js.go, rand_js.go, roundtrip_js.go init
 	//
diff --git a/internal/gojs/config/config.go b/internal/gojs/config/config.go
index dab63bfe..4fb23935 100644
--- a/internal/gojs/config/config.go
+++ b/internal/gojs/config/config.go
@@ -3,23 +3,41 @@
 package config
 
 import (
+	"fmt"
 	"net/http"
 	"os"
 	"path/filepath"
+	"runtime"
+	"syscall"
 
 	"github.com/tetratelabs/wazero/internal/platform"
 )
 
 type Config struct {
 	OsWorkdir bool
-	Rt        http.RoundTripper
+	OsUser    bool
+
+	Uid, Gid, Euid int
+	Groups         []int
 
 	// Workdir is the actual working directory value.
 	Workdir string
+	Umask   uint32
+	Rt      http.RoundTripper
 }
 
 func NewConfig() *Config {
-	return &Config{Workdir: "/"}
+	return &Config{
+		OsWorkdir: false,
+		OsUser:    false,
+		Uid:       0,
+		Gid:       0,
+		Euid:      0,
+		Groups:    []int{0},
+		Workdir:   "/",
+		Umask:     uint32(0o0022),
+		Rt:        nil,
+	}
 }
 
 func (c *Config) Clone() *Config {
@@ -38,5 +56,16 @@ func (c *Config) Init() error {
 		// Strip the volume of the path, for example C:\
 		c.Workdir = workdir[len(filepath.VolumeName(workdir)):]
 	}
+
+	// Windows does not support any of these properties
+	if c.OsUser && runtime.GOOS != "windows" {
+		c.Uid = syscall.Getuid()
+		c.Gid = syscall.Getgid()
+		c.Euid = syscall.Geteuid()
+		var err error
+		if c.Groups, err = syscall.Getgroups(); err != nil {
+			return fmt.Errorf("couldn't read groups: %w", err)
+		}
+	}
 	return nil
 }
diff --git a/internal/gojs/fs.go b/internal/gojs/fs.go
index bc60ecd1..280ab9eb 100644
--- a/internal/gojs/fs.go
+++ b/internal/gojs/fs.go
@@ -6,12 +6,12 @@ import (
 	"io"
 	"io/fs"
 	"os"
-	"path"
 	"syscall"
 
 	"github.com/tetratelabs/wazero/api"
 	"github.com/tetratelabs/wazero/internal/gojs/custom"
 	"github.com/tetratelabs/wazero/internal/gojs/goos"
+	"github.com/tetratelabs/wazero/internal/gojs/util"
 	"github.com/tetratelabs/wazero/internal/platform"
 	internalsys "github.com/tetratelabs/wazero/internal/sys"
 	"github.com/tetratelabs/wazero/internal/sysfs"
@@ -19,40 +19,6 @@ import (
 )
 
 var (
-	// jsfs = js.Global().Get("fs") // fs_js.go init
-	//
-	// js.fsCall conventions:
-	// * funcWrapper callback is the last parameter
-	//   * arg0 is error and up to one result in arg1
-	jsfs = newJsVal(goos.RefJsfs, custom.NameFs).
-		addProperties(map[string]interface{}{
-			"constants": jsfsConstants, // = jsfs.Get("constants") // init
-		}).
-		addFunction(custom.NameFsOpen, jsfsOpen{}).
-		addFunction(custom.NameFsStat, jsfsStat{}).
-		addFunction(custom.NameFsFstat, jsfsFstat{}).
-		addFunction(custom.NameFsLstat, jsfsLstat{}).
-		addFunction(custom.NameFsClose, jsfsClose{}).
-		addFunction(custom.NameFsRead, jsfsRead{}).
-		addFunction(custom.NameFsWrite, jsfsWrite{}).
-		addFunction(custom.NameFsReaddir, jsfsReaddir{}).
-		addFunction(custom.NameFsMkdir, jsfsMkdir{}).
-		addFunction(custom.NameFsRmdir, jsfsRmdir{}).
-		addFunction(custom.NameFsRename, jsfsRename{}).
-		addFunction(custom.NameFsUnlink, jsfsUnlink{}).
-		addFunction(custom.NameFsUtimes, jsfsUtimes{}).
-		addFunction(custom.NameFsChmod, jsfsChmod{}).
-		addFunction(custom.NameFsFchmod, jsfsFchmod{}).
-		addFunction(custom.NameFsChown, jsfsChown{}).
-		addFunction(custom.NameFsFchown, jsfsFchown{}).
-		addFunction(custom.NameFsLchown, jsfsLchown{}).
-		addFunction(custom.NameFsTruncate, jsfsTruncate{}).
-		addFunction(custom.NameFsFtruncate, jsfsFtruncate{}).
-		addFunction(custom.NameFsReadlink, jsfsReadlink{}).
-		addFunction(custom.NameFsLink, jsfsLink{}).
-		addFunction(custom.NameFsSymlink, jsfsSymlink{}).
-		addFunction(custom.NameFsFsync, jsfsFsync{})
-
 	// jsfsConstants = jsfs Get("constants") // fs_js.go init
 	jsfsConstants = newJsVal(goos.RefJsfsConstants, "constants").
 			addProperties(map[string]interface{}{
@@ -93,15 +59,53 @@ type (
 	truncateFile interface{ Truncate(size int64) error }
 )
 
+// jsfs = js.Global().Get("fs") // fs_js.go init
+//
+// js.fsCall conventions:
+// * funcWrapper callback is the last parameter
+//   - arg0 is error and up to one result in arg1
+func newJsFs(proc *processState) *jsVal {
+	return newJsVal(goos.RefJsfs, custom.NameFs).
+		addProperties(map[string]interface{}{
+			"constants": jsfsConstants, // = jsfs.Get("constants") // init
+		}).
+		addFunction(custom.NameFsOpen, &jsfsOpen{proc: proc}).
+		addFunction(custom.NameFsStat, &jsfsStat{proc: proc}).
+		addFunction(custom.NameFsFstat, jsfsFstat{}).
+		addFunction(custom.NameFsLstat, &jsfsLstat{proc: proc}).
+		addFunction(custom.NameFsClose, jsfsClose{}).
+		addFunction(custom.NameFsRead, jsfsRead{}).
+		addFunction(custom.NameFsWrite, jsfsWrite{}).
+		addFunction(custom.NameFsReaddir, &jsfsReaddir{proc: proc}).
+		addFunction(custom.NameFsMkdir, &jsfsMkdir{proc: proc}).
+		addFunction(custom.NameFsRmdir, &jsfsRmdir{proc: proc}).
+		addFunction(custom.NameFsRename, &jsfsRename{proc: proc}).
+		addFunction(custom.NameFsUnlink, &jsfsUnlink{proc: proc}).
+		addFunction(custom.NameFsUtimes, &jsfsUtimes{proc: proc}).
+		addFunction(custom.NameFsChmod, &jsfsChmod{proc: proc}).
+		addFunction(custom.NameFsFchmod, jsfsFchmod{}).
+		addFunction(custom.NameFsChown, &jsfsChown{proc: proc}).
+		addFunction(custom.NameFsFchown, jsfsFchown{}).
+		addFunction(custom.NameFsLchown, &jsfsLchown{proc: proc}).
+		addFunction(custom.NameFsTruncate, &jsfsTruncate{proc: proc}).
+		addFunction(custom.NameFsFtruncate, jsfsFtruncate{}).
+		addFunction(custom.NameFsReadlink, &jsfsReadlink{proc: proc}).
+		addFunction(custom.NameFsLink, &jsfsLink{proc: proc}).
+		addFunction(custom.NameFsSymlink, &jsfsSymlink{proc: proc}).
+		addFunction(custom.NameFsFsync, jsfsFsync{})
+}
+
 // jsfsOpen implements implements jsFn for syscall.Open
 //
 //	jsFD /* Int */, err := fsCall("open", path, flags, perm)
-type jsfsOpen struct{}
+type jsfsOpen struct {
+	proc *processState
+}
 
-func (jsfsOpen) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
-	path := resolvePath(ctx, args[0].(string))
+func (o *jsfsOpen) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
+	path := util.ResolvePath(o.proc.cwd, args[0].(string))
 	flags := toUint64(args[1]) // flags are derived from constants like oWRONLY
-	perm := getPerm(ctx, goos.ValueToUint32(args[2]))
+	perm := custom.FromJsMode(goos.ValueToUint32(args[2]), o.proc.umask)
 	callback := args[3].(funcWrapper)
 
 	fsc := mod.(*wasm.CallContext).Sys.FS()
@@ -114,10 +118,12 @@ func (jsfsOpen) invoke(ctx context.Context, mod api.Module, args ...interface{})
 // jsfsStat implements jsFn for syscall.Stat
 //
 //	jsSt, err := fsCall("stat", path)
-type jsfsStat struct{}
+type jsfsStat struct {
+	proc *processState
+}
 
-func (jsfsStat) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
-	path := resolvePath(ctx, args[0].(string))
+func (s *jsfsStat) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
+	path := util.ResolvePath(s.proc.cwd, args[0].(string))
 	callback := args[1].(funcWrapper)
 
 	stat, err := syscallStat(mod, path)
@@ -138,10 +144,12 @@ func syscallStat(mod api.Module, path string) (*jsSt, error) {
 // jsfsLstat implements jsFn for syscall.Lstat
 //
 //	jsSt, err := fsCall("lstat", path)
-type jsfsLstat struct{}
+type jsfsLstat struct {
+	proc *processState
+}
 
-func (jsfsLstat) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
-	path := resolvePath(ctx, args[0].(string))
+func (l *jsfsLstat) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
+	path := util.ResolvePath(l.proc.cwd, args[0].(string))
 	callback := args[1].(funcWrapper)
 
 	lstat, err := syscallLstat(mod, path)
@@ -194,6 +202,8 @@ func newJsSt(st *platform.Stat_t) *jsSt {
 	ret.isDir = st.Mode.IsDir()
 	ret.dev = st.Dev
 	ret.ino = st.Ino
+	ret.uid = st.Uid
+	ret.gid = st.Gid
 	ret.mode = custom.ToJsMode(st.Mode)
 	ret.nlink = uint32(st.Nlink)
 	ret.size = st.Size
@@ -319,10 +329,12 @@ func syscallWrite(mod api.Module, fd uint32, offset interface{}, p []byte) (n ui
 //
 //	dir, err := fsCall("readdir", path)
 //		dir.Length(), dir.Index(i).String()
-type jsfsReaddir struct{}
+type jsfsReaddir struct {
+	proc *processState
+}
 
-func (jsfsReaddir) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
-	path := resolvePath(ctx, args[0].(string))
+func (r *jsfsReaddir) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
+	path := util.ResolvePath(r.proc.cwd, args[0].(string))
 	callback := args[1].(funcWrapper)
 
 	stat, err := syscallReaddir(ctx, mod, path)
@@ -350,64 +362,16 @@ func syscallReaddir(_ context.Context, mod api.Module, name string) (*objectArra
 	}
 }
 
-// returnZero implements jsFn
-type returnZero struct{}
-
-func (returnZero) invoke(context.Context, api.Module, ...interface{}) (interface{}, error) {
-	return goos.RefValueZero, nil
-}
-
-// returnSliceOfZero implements jsFn
-type returnSliceOfZero struct{}
-
-func (returnSliceOfZero) invoke(context.Context, api.Module, ...interface{}) (interface{}, error) {
-	return &objectArray{slice: []interface{}{goos.RefValueZero}}, nil
-}
-
-// processCwd implements jsFn for fs.Open syscall.Getcwd in fs_js.go
-type processCwd struct{}
-
-func (processCwd) invoke(ctx context.Context, _ api.Module, _ ...interface{}) (interface{}, error) {
-	return getState(ctx).cwd, nil
-}
-
-// processChdir implements jsFn for fs.Open syscall.Chdir in fs_js.go
-type processChdir struct{}
-
-func (processChdir) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
-	path := path.Clean(args[0].(string))
-
-	if s, err := syscallStat(mod, path); err != nil {
-		return nil, err
-	} else if !s.isDir {
-		return nil, syscall.ENOTDIR
-	} else {
-		getState(ctx).cwd = path
-		return nil, nil
-	}
-}
-
-// processUmask implements jsFn for fs.Open syscall.Umask in fs_js.go
-type processUmask struct{}
-
-func (processUmask) invoke(ctx context.Context, _ api.Module, args ...interface{}) (interface{}, error) {
-	mask := goos.ValueToUint32(args[0])
-
-	s := getState(ctx)
-	oldmask := s.umask
-	s.umask = mask
-
-	return oldmask, nil
-}
-
 // jsfsMkdir implements implements jsFn for fs.Mkdir
 //
 //	jsFD /* Int */, err := fsCall("mkdir", path, perm)
-type jsfsMkdir struct{}
+type jsfsMkdir struct {
+	proc *processState
+}
 
-func (jsfsMkdir) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
-	path := resolvePath(ctx, args[0].(string))
-	perm := getPerm(ctx, goos.ValueToUint32(args[1]))
+func (m *jsfsMkdir) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
+	path := util.ResolvePath(m.proc.cwd, args[0].(string))
+	perm := custom.FromJsMode(goos.ValueToUint32(args[1]), m.proc.umask)
 	callback := args[2].(funcWrapper)
 
 	fsc := mod.(*wasm.CallContext).Sys.FS()
@@ -429,10 +393,12 @@ func (jsfsMkdir) invoke(ctx context.Context, mod api.Module, args ...interface{}
 // jsfsRmdir implements jsFn for the following
 //
 //	_, err := fsCall("rmdir", path) // syscall.Rmdir
-type jsfsRmdir struct{}
+type jsfsRmdir struct {
+	proc *processState
+}
 
-func (jsfsRmdir) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
-	path := resolvePath(ctx, args[0].(string))
+func (r *jsfsRmdir) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
+	path := util.ResolvePath(r.proc.cwd, args[0].(string))
 	callback := args[1].(funcWrapper)
 
 	fsc := mod.(*wasm.CallContext).Sys.FS()
@@ -444,11 +410,14 @@ func (jsfsRmdir) invoke(ctx context.Context, mod api.Module, args ...interface{}
 // jsfsRename implements jsFn for the following
 //
 //	_, err := fsCall("rename", from, to) // syscall.Rename
-type jsfsRename struct{}
+type jsfsRename struct {
+	proc *processState
+}
 
-func (jsfsRename) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
-	from := resolvePath(ctx, args[0].(string))
-	to := resolvePath(ctx, args[1].(string))
+func (r *jsfsRename) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
+	cwd := r.proc.cwd
+	from := util.ResolvePath(cwd, args[0].(string))
+	to := util.ResolvePath(cwd, args[1].(string))
 	callback := args[2].(funcWrapper)
 
 	fsc := mod.(*wasm.CallContext).Sys.FS()
@@ -460,10 +429,12 @@ func (jsfsRename) invoke(ctx context.Context, mod api.Module, args ...interface{
 // jsfsUnlink implements jsFn for the following
 //
 //	_, err := fsCall("unlink", path) // syscall.Unlink
-type jsfsUnlink struct{}
+type jsfsUnlink struct {
+	proc *processState
+}
 
-func (jsfsUnlink) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
-	path := resolvePath(ctx, args[0].(string))
+func (u *jsfsUnlink) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
+	path := util.ResolvePath(u.proc.cwd, args[0].(string))
 	callback := args[1].(funcWrapper)
 
 	fsc := mod.(*wasm.CallContext).Sys.FS()
@@ -475,10 +446,12 @@ func (jsfsUnlink) invoke(ctx context.Context, mod api.Module, args ...interface{
 // jsfsUtimes implements jsFn for the following
 //
 //	_, err := fsCall("utimes", path, atime, mtime) // syscall.Utimens
-type jsfsUtimes struct{}
+type jsfsUtimes struct {
+	proc *processState
+}
 
-func (jsfsUtimes) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
-	path := resolvePath(ctx, args[0].(string))
+func (u *jsfsUtimes) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
+	path := util.ResolvePath(u.proc.cwd, args[0].(string))
 	atimeSec := toInt64(args[1])
 	mtimeSec := toInt64(args[2])
 	callback := args[3].(funcWrapper)
@@ -495,10 +468,12 @@ func (jsfsUtimes) invoke(ctx context.Context, mod api.Module, args ...interface{
 // jsfsChmod implements jsFn for the following
 //
 //	_, err := fsCall("chmod", path, mode) // syscall.Chmod
-type jsfsChmod struct{}
+type jsfsChmod struct {
+	proc *processState
+}
 
-func (jsfsChmod) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
-	path := resolvePath(ctx, args[0].(string))
+func (c *jsfsChmod) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
+	path := util.ResolvePath(c.proc.cwd, args[0].(string))
 	mode := custom.FromJsMode(goos.ValueToUint32(args[1]), 0)
 	callback := args[2].(funcWrapper)
 
@@ -535,10 +510,12 @@ func (jsfsFchmod) invoke(ctx context.Context, mod api.Module, args ...interface{
 // jsfsChown implements jsFn for the following
 //
 //	_, err := fsCall("chown", path, uint32(uid), uint32(gid)) // syscall.Chown
-type jsfsChown struct{}
+type jsfsChown struct {
+	proc *processState
+}
 
-func (jsfsChown) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
-	path := resolvePath(ctx, args[0].(string))
+func (c *jsfsChown) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
+	path := util.ResolvePath(c.proc.cwd, args[0].(string))
 	uid := goos.ValueToInt32(args[1])
 	gid := goos.ValueToInt32(args[2])
 	callback := args[3].(funcWrapper)
@@ -575,10 +552,12 @@ func (jsfsFchown) invoke(ctx context.Context, mod api.Module, args ...interface{
 // jsfsLchown implements jsFn for the following
 //
 //	_, err := fsCall("lchown", path, uint32(uid), uint32(gid)) // syscall.Lchown
-type jsfsLchown struct{}
+type jsfsLchown struct {
+	proc *processState
+}
 
-func (jsfsLchown) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
-	path := resolvePath(ctx, args[0].(string))
+func (l *jsfsLchown) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
+	path := util.ResolvePath(l.proc.cwd, args[0].(string))
 	uid := goos.ValueToUint32(args[1])
 	gid := goos.ValueToUint32(args[2])
 	callback := args[3].(funcWrapper)
@@ -592,10 +571,12 @@ func (jsfsLchown) invoke(ctx context.Context, mod api.Module, args ...interface{
 // jsfsTruncate implements jsFn for the following
 //
 //	_, err := fsCall("truncate", path, length) // syscall.Truncate
-type jsfsTruncate struct{}
+type jsfsTruncate struct {
+	proc *processState
+}
 
-func (jsfsTruncate) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
-	path := resolvePath(ctx, args[0].(string))
+func (t *jsfsTruncate) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
+	path := util.ResolvePath(t.proc.cwd, args[0].(string))
 	length := toInt64(args[1])
 	callback := args[2].(funcWrapper)
 
@@ -632,10 +613,12 @@ func (jsfsFtruncate) invoke(ctx context.Context, mod api.Module, args ...interfa
 // jsfsReadlink implements jsFn for syscall.Readlink
 //
 //	dst, err := fsCall("readlink", path) // syscall.Readlink
-type jsfsReadlink struct{}
+type jsfsReadlink struct {
+	proc *processState
+}
 
-func (jsfsReadlink) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
-	path := resolvePath(ctx, args[0].(string))
+func (r *jsfsReadlink) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
+	path := util.ResolvePath(r.proc.cwd, args[0].(string))
 	callback := args[1].(funcWrapper)
 
 	fsc := mod.(*wasm.CallContext).Sys.FS()
@@ -647,11 +630,14 @@ func (jsfsReadlink) invoke(ctx context.Context, mod api.Module, args ...interfac
 // jsfsLink implements jsFn for the following
 //
 //	_, err := fsCall("link", path, link) // syscall.Link
-type jsfsLink struct{}
+type jsfsLink struct {
+	proc *processState
+}
 
-func (jsfsLink) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
-	path := resolvePath(ctx, args[0].(string))
-	link := resolvePath(ctx, args[1].(string))
+func (l *jsfsLink) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
+	cwd := l.proc.cwd
+	path := util.ResolvePath(cwd, args[0].(string))
+	link := util.ResolvePath(cwd, args[1].(string))
 	callback := args[2].(funcWrapper)
 
 	fsc := mod.(*wasm.CallContext).Sys.FS()
@@ -663,11 +649,13 @@ func (jsfsLink) invoke(ctx context.Context, mod api.Module, args ...interface{})
 // jsfsSymlink implements jsFn for the following
 //
 //	_, err := fsCall("symlink", path, link) // syscall.Symlink
-type jsfsSymlink struct{}
+type jsfsSymlink struct {
+	proc *processState
+}
 
-func (jsfsSymlink) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
+func (s *jsfsSymlink) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
 	dst := args[0].(string) // The dst of a symlink must not be resolved, as it should be resolved during readLink.
-	link := resolvePath(ctx, args[1].(string))
+	link := util.ResolvePath(s.proc.cwd, args[1].(string))
 	callback := args[2].(funcWrapper)
 
 	fsc := mod.(*wasm.CallContext).Sys.FS()
@@ -723,7 +711,7 @@ func (s *jsSt) String() string {
 }
 
 // Get implements the same method as documented on goos.GetFunction
-func (s *jsSt) Get(_ context.Context, propertyKey string) interface{} {
+func (s *jsSt) Get(propertyKey string) interface{} {
 	switch propertyKey {
 	case "dev":
 		return s.dev
@@ -766,25 +754,3 @@ func (s *jsSt) call(_ context.Context, _ api.Module, _ goos.Ref, method string,
 func jsfsInvoke(ctx context.Context, mod api.Module, callback funcWrapper, err error) (interface{}, error) {
 	return callback.invoke(ctx, mod, goos.RefJsfs, err, err == nil) // note: error first
 }
-
-// resolvePath is needed when a non-absolute path is given to a function.
-// Unlike other host ABI, GOOS=js maintains the CWD host side.
-func resolvePath(ctx context.Context, path string) string {
-	if len(path) == 0 || path[0] == '/' {
-		return path // leave alone .. or absolute paths.
-	}
-	return joinPath(getState(ctx).cwd, path)
-}
-
-// joinPath avoids us having to rename fields just to avoid conflict with the
-// path package.
-func joinPath(dirName, baseName string) string {
-	return path.Join(dirName, baseName)
-}
-
-// getPerm converts the input js permissions to a go-compatible one, after
-// subtracting the current umask.
-func getPerm(ctx context.Context, perm uint32) fs.FileMode {
-	umask := getState(ctx).umask
-	return custom.FromJsMode(perm, umask)
-}
diff --git a/internal/gojs/goos/goos.go b/internal/gojs/goos/goos.go
index c56ab0d3..477b02a3 100644
--- a/internal/gojs/goos/goos.go
+++ b/internal/gojs/goos/goos.go
@@ -276,7 +276,7 @@ func ValueToInt32(arg interface{}) int32 {
 
 // GetFunction allows getting a JavaScript property by name.
 type GetFunction interface {
-	Get(ctx context.Context, propertyKey string) interface{}
+	Get(propertyKey string) interface{}
 }
 
 // ByteArray is a result of uint8ArrayConstructor which temporarily stores
@@ -297,7 +297,7 @@ func (a *ByteArray) Unwrap() []byte {
 }
 
 // Get implements GetFunction
-func (a *ByteArray) Get(_ context.Context, propertyKey string) interface{} {
+func (a *ByteArray) Get(propertyKey string) interface{} {
 	switch propertyKey {
 	case "byteLength":
 		return uint32(len(a.slice))
diff --git a/internal/gojs/http.go b/internal/gojs/http.go
index 1026d0d7..13130dfe 100644
--- a/internal/gojs/http.go
+++ b/internal/gojs/http.go
@@ -67,7 +67,7 @@ type fetchResult struct {
 }
 
 // Get implements the same method as documented on goos.GetFunction
-func (s *fetchResult) Get(_ context.Context, propertyKey string) interface{} {
+func (s *fetchResult) Get(propertyKey string) interface{} {
 	switch propertyKey {
 	case "headers":
 		names := make([]string, 0, len(s.res.Header))
@@ -104,7 +104,7 @@ type headers struct {
 }
 
 // Get implements the same method as documented on goos.GetFunction
-func (h *headers) Get(_ context.Context, propertyKey string) interface{} {
+func (h *headers) Get(propertyKey string) interface{} {
 	switch propertyKey {
 	case "done":
 		return h.i == len(h.names)
diff --git a/internal/gojs/js.go b/internal/gojs/js.go
index bf419ada..2667fa6e 100644
--- a/internal/gojs/js.go
+++ b/internal/gojs/js.go
@@ -51,7 +51,7 @@ func (v *jsVal) addFunction(method string, fn jsFn) *jsVal {
 }
 
 // Get implements the same method as documented on goos.GetFunction
-func (v *jsVal) Get(_ context.Context, propertyKey string) interface{} {
+func (v *jsVal) Get(propertyKey string) interface{} {
 	if v, ok := v.properties[propertyKey]; ok {
 		return v
 	}
diff --git a/internal/gojs/process.go b/internal/gojs/process.go
new file mode 100644
index 00000000..adcf17da
--- /dev/null
+++ b/internal/gojs/process.go
@@ -0,0 +1,103 @@
+package gojs
+
+import (
+	"context"
+	"path"
+	"syscall"
+
+	"github.com/tetratelabs/wazero/api"
+	"github.com/tetratelabs/wazero/internal/gojs/custom"
+	"github.com/tetratelabs/wazero/internal/gojs/goos"
+	"github.com/tetratelabs/wazero/internal/gojs/util"
+)
+
+// processState are the mutable fields of the current process.
+type processState struct {
+	cwd   string
+	umask uint32
+}
+
+func newJsProcess(uid, gid, euid int, groups []int, proc *processState) *jsVal {
+	uidRef := toFloatRef(float64(uid))
+	gidRef := toFloatRef(float64(gid))
+	euidRef := toFloatRef(float64(euid))
+	groupSlice := make([]interface{}, 0, len(groups))
+	for _, group := range groups {
+		groupSlice = append(groupSlice, toFloatRef(float64(group)))
+	}
+
+	// jsProcess = js.Global().Get("process") // fs_js.go init
+	return newJsVal(goos.RefJsProcess, custom.NameProcess).
+		addProperties(map[string]interface{}{
+			"pid":  float64(1),        // Get("pid").Int() in syscall_js.go for syscall.Getpid
+			"ppid": goos.RefValueZero, // Get("ppid").Int() in syscall_js.go for syscall.Getppid
+		}).
+		addFunction(custom.NameProcessCwd, &processCwd{proc: proc}).       // syscall.Cwd in fs_js.go
+		addFunction(custom.NameProcessChdir, &processChdir{proc: proc}).   // syscall.Chdir in fs_js.go
+		addFunction(custom.NameProcessGetuid, getId(uidRef)).              // syscall.Getuid in syscall_js.go
+		addFunction(custom.NameProcessGetgid, getId(gidRef)).              // syscall.Getgid in syscall_js.go
+		addFunction(custom.NameProcessGeteuid, getId(euidRef)).            // syscall.Geteuid in syscall_js.go
+		addFunction(custom.NameProcessGetgroups, returnSlice(groupSlice)). // syscall.Getgroups in syscall_js.go
+		addFunction(custom.NameProcessUmask, &processUmask{proc: proc})    // syscall.Umask in syscall_js.go
+}
+
+// processCwd implements jsFn for fs.Open syscall.Getcwd in fs_js.go
+type processCwd struct {
+	proc *processState
+}
+
+func (p *processCwd) invoke(_ context.Context, _ api.Module, _ ...interface{}) (interface{}, error) {
+	return p.proc.cwd, nil
+}
+
+// processChdir implements jsFn for fs.Open syscall.Chdir in fs_js.go
+type processChdir struct {
+	proc *processState
+}
+
+func (p *processChdir) invoke(_ context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
+	oldWd := p.proc.cwd
+	newWd := util.ResolvePath(oldWd, args[0].(string))
+
+	newWd = path.Clean(newWd)
+	if newWd == oldWd { // handle .
+		return nil, nil
+	}
+
+	if s, err := syscallStat(mod, newWd); err != nil {
+		return nil, err
+	} else if !s.isDir {
+		return nil, syscall.ENOTDIR
+	} else {
+		p.proc.cwd = newWd
+		return nil, nil
+	}
+}
+
+// processUmask implements jsFn for fs.Open syscall.Umask in fs_js.go
+type processUmask struct {
+	proc *processState
+}
+
+func (p *processUmask) invoke(_ context.Context, _ api.Module, args ...interface{}) (interface{}, error) {
+	newUmask := goos.ValueToUint32(args[0])
+
+	oldUmask := p.proc.umask
+	p.proc.umask = newUmask
+
+	return oldUmask, nil
+}
+
+// getId implements jsFn for syscall.Getuid, syscall.Getgid and syscall.Geteuid in syscall_js.go
+type getId goos.Ref
+
+func (i getId) invoke(_ context.Context, _ api.Module, _ ...interface{}) (interface{}, error) {
+	return goos.Ref(i), nil
+}
+
+// returnSlice implements jsFn for syscall.Getgroups in syscall_js.go
+type returnSlice []interface{}
+
+func (s returnSlice) invoke(context.Context, api.Module, ...interface{}) (interface{}, error) {
+	return &objectArray{slice: s}, nil
+}
diff --git a/internal/gojs/state.go b/internal/gojs/state.go
index e0380a53..57ba3ea1 100644
--- a/internal/gojs/state.go
+++ b/internal/gojs/state.go
@@ -14,9 +14,7 @@ import (
 func NewState(config *config.Config) *State {
 	return &State{
 		values:                 values.NewValues(),
-		valueGlobal:            newJsGlobal(config.Rt),
-		cwd:                    config.Workdir,
-		umask:                  0o0022,
+		valueGlobal:            newJsGlobal(config),
 		_nextCallbackTimeoutID: 1,
 		_scheduledTimeouts:     map[uint32]chan bool{},
 	}
@@ -48,7 +46,7 @@ type event struct {
 }
 
 // Get implements the same method as documented on goos.GetFunction
-func (e *event) Get(_ context.Context, propertyKey string) interface{} {
+func (e *event) Get(propertyKey string) interface{} {
 	switch propertyKey {
 	case "id":
 		return e.id
@@ -89,9 +87,9 @@ func LoadValue(ctx context.Context, ref goos.Ref) interface{} { //nolint
 	case goos.RefArrayConstructor:
 		return arrayConstructor
 	case goos.RefJsProcess:
-		return jsProcess
+		return getState(ctx).valueGlobal.Get("process")
 	case goos.RefJsfs:
-		return jsfs
+		return getState(ctx).valueGlobal.Get("fs")
 	case goos.RefJsfsConstants:
 		return jsfsConstants
 	case goos.RefUint8ArrayConstructor:
@@ -180,15 +178,10 @@ type State struct {
 
 	_nextCallbackTimeoutID uint32
 	_scheduledTimeouts     map[uint32]chan bool
-
-	// cwd is initially "/"
-	cwd string
-	// umask is initially 0022
-	umask uint32
 }
 
 // Get implements the same method as documented on goos.GetFunction
-func (s *State) Get(_ context.Context, propertyKey string) interface{} {
+func (s *State) Get(propertyKey string) interface{} {
 	switch propertyKey {
 	case "_pendingEvent":
 		return s._pendingEvent
@@ -220,8 +213,6 @@ func (s *State) close() {
 	s._pendingEvent = nil
 	s._lastEvent = nil
 	s._nextCallbackTimeoutID = 1
-	s.cwd = "/"
-	s.umask = 0o0022
 }
 
 func toInt64(arg interface{}) int64 {
diff --git a/internal/gojs/syscall.go b/internal/gojs/syscall.go
index 1edc7885..8a8dca15 100644
--- a/internal/gojs/syscall.go
+++ b/internal/gojs/syscall.go
@@ -55,7 +55,7 @@ func valueGet(ctx context.Context, mod api.Module, stack goos.Stack) {
 
 	var result interface{}
 	if g, ok := v.(goos.GetFunction); ok {
-		result = g.Get(ctx, p)
+		result = g.Get(p)
 	} else if e, ok := v.(error); ok {
 		switch p {
 		case "message": // js (GOOS=js) error, can be anything.
diff --git a/internal/gojs/util/util.go b/internal/gojs/util/util.go
index 8c0fba4b..21db4c92 100644
--- a/internal/gojs/util/util.go
+++ b/internal/gojs/util/util.go
@@ -2,6 +2,7 @@ package util
 
 import (
 	"fmt"
+	pathutil "path"
 
 	"github.com/tetratelabs/wazero/api"
 	"github.com/tetratelabs/wazero/internal/gojs/custom"
@@ -44,3 +45,26 @@ func NewFunc(name string, goFunc api.GoModuleFunc) *wasm.HostFunc {
 		Code:        wasm.Code{GoFunc: goFunc},
 	}
 }
+
+// ResolvePath is needed when a non-absolute path is given to a function.
+// Unlike other host ABI, GOOS=js maintains the CWD host side.
+func ResolvePath(cwd, path string) (resolved string) {
+	pathLen := len(path)
+	switch {
+	case pathLen == 0:
+		return cwd
+	case pathLen == 1 && path[0] == '.':
+		return cwd
+	case path[0] == '/':
+		resolved = pathutil.Clean(path)
+	default:
+		resolved = pathutil.Join(cwd, path)
+	}
+
+	// If there's a trailing slash, we need to retain it for symlink edge
+	// cases. See https://github.com/golang/go/issues/27225
+	if len(resolved) > 1 && path[pathLen-1] == '/' {
+		return resolved + "/"
+	}
+	return resolved
+}
diff --git a/internal/platform/stat.go b/internal/platform/stat.go
index e04be140..f41b3e5f 100644
--- a/internal/platform/stat.go
+++ b/internal/platform/stat.go
@@ -19,6 +19,14 @@ type Stat_t struct {
 	// Ino is the file serial number.
 	Ino uint64
 
+	// Uid is the user ID that owns the file, or zero if unsupported.
+	// For example, this is unsupported on some virtual filesystems or windows.
+	Uid uint32
+
+	// Gid is the group ID that owns the file, or zero if unsupported.
+	// For example, this is unsupported on some virtual filesystems or windows.
+	Gid uint32
+
 	// Mode is the same as Mode on fs.FileInfo containing bits to identify the
 	// type of the file (fs.ModeType) and its permissions (fs.ModePerm).
 	Mode fs.FileMode
diff --git a/internal/platform/stat_bsd.go b/internal/platform/stat_bsd.go
index c2e1ffc6..4e8c9a20 100644
--- a/internal/platform/stat_bsd.go
+++ b/internal/platform/stat_bsd.go
@@ -37,8 +37,10 @@ func inoFromFileInfo(_ readdirFile, t fs.FileInfo) (ino uint64, err error) {
 
 func fillStatFromFileInfo(st *Stat_t, t fs.FileInfo) {
 	if d, ok := t.Sys().(*syscall.Stat_t); ok {
-		st.Ino = d.Ino
 		st.Dev = uint64(d.Dev)
+		st.Ino = d.Ino
+		st.Uid = d.Uid
+		st.Gid = d.Gid
 		st.Mode = t.Mode()
 		st.Nlink = uint64(d.Nlink)
 		st.Size = d.Size
diff --git a/internal/platform/stat_linux.go b/internal/platform/stat_linux.go
index 55487192..03e94555 100644
--- a/internal/platform/stat_linux.go
+++ b/internal/platform/stat_linux.go
@@ -40,8 +40,10 @@ func inoFromFileInfo(_ readdirFile, t fs.FileInfo) (ino uint64, err error) {
 
 func fillStatFromFileInfo(st *Stat_t, t fs.FileInfo) {
 	if d, ok := t.Sys().(*syscall.Stat_t); ok {
-		st.Ino = uint64(d.Ino)
 		st.Dev = uint64(d.Dev)
+		st.Ino = uint64(d.Ino)
+		st.Uid = d.Uid
+		st.Gid = d.Gid
 		st.Mode = t.Mode()
 		st.Nlink = uint64(d.Nlink)
 		st.Size = d.Size


Test patch:
diff --git a/internal/gojs/config/config_test.go b/internal/gojs/config/config_test.go
index 870231b7..ce32b564 100644
--- a/internal/gojs/config/config_test.go
+++ b/internal/gojs/config/config_test.go
@@ -1,7 +1,9 @@
 package config
 
 import (
+	"runtime"
 	"strings"
+	"syscall"
 	"testing"
 
 	"github.com/tetratelabs/wazero/internal/testing/require"
@@ -10,8 +12,37 @@ import (
 func TestConfig_Init(t *testing.T) {
 	t.Parallel()
 
-	t.Run("OsWorkdir", func(t *testing.T) {
-		c := &Config{OsWorkdir: true}
+	t.Run("User", func(t *testing.T) {
+		c := NewConfig()
+
+		// values should be 0 which is root
+		require.Equal(t, 0, c.Uid)
+		require.Equal(t, 0, c.Gid)
+		require.Equal(t, 0, c.Euid)
+		require.Equal(t, []int{0}, c.Groups)
+		require.False(t, c.OsUser)
+
+		if runtime.GOOS != "windows" {
+			c.OsUser = true
+			require.NoError(t, c.Init())
+
+			require.Equal(t, syscall.Getuid(), c.Uid)
+			require.Equal(t, syscall.Getgid(), c.Gid)
+			require.Equal(t, syscall.Geteuid(), c.Euid)
+
+			groups, err := syscall.Getgroups()
+			require.NoError(t, err)
+			require.Equal(t, groups, c.Groups)
+		}
+	})
+
+	t.Run("Workdir", func(t *testing.T) {
+		c := NewConfig()
+		require.Equal(t, "/", c.Workdir)
+		require.False(t, c.OsWorkdir)
+
+		c.OsWorkdir = true
+
 		require.NoError(t, c.Init())
 		actual := c.Workdir
 
diff --git a/internal/gojs/fs_test.go b/internal/gojs/fs_test.go
index b5dae811..81def6d3 100644
--- a/internal/gojs/fs_test.go
+++ b/internal/gojs/fs_test.go
@@ -21,9 +21,7 @@ func Test_fs(t *testing.T) {
 
 	require.Zero(t, stderr)
 	require.EqualError(t, err, `module "" closed with exit_code(0)`)
-	require.Equal(t, `wd ok
-Not a directory
-sub mode drwxr-xr-x
+	require.Equal(t, `sub mode drwxr-xr-x
 /animals.txt mode -rw-r--r--
 animals.txt mode -rw-r--r--
 contents: bear
diff --git a/internal/gojs/http_test.go b/internal/gojs/http_test.go
index c8cc2dbe..90ae0cf6 100644
--- a/internal/gojs/http_test.go
+++ b/internal/gojs/http_test.go
@@ -41,9 +41,9 @@ func Test_http(t *testing.T) {
 	})
 
 	stdout, stderr, err := compileAndRun(testCtx, "http", func(moduleConfig wazero.ModuleConfig) (wazero.ModuleConfig, *config.Config) {
-		return moduleConfig.WithEnv("BASE_URL", "http://host"), &config.Config{
-			Rt: rt,
-		}
+		config := config.NewConfig()
+		config.Rt = rt
+		return moduleConfig.WithEnv("BASE_URL", "http://host"), config
 	})
 
 	require.EqualError(t, err, `module "" closed with exit_code(0)`)
diff --git a/internal/gojs/syscall_test.go b/internal/gojs/process_test.go
similarity index 51%
rename from internal/gojs/syscall_test.go
rename to internal/gojs/process_test.go
index e2b81df7..1e2a9c1c 100644
--- a/internal/gojs/syscall_test.go
+++ b/internal/gojs/process_test.go
@@ -1,18 +1,24 @@
 package gojs_test
 
 import (
+	"os"
 	"testing"
 
+	"github.com/tetratelabs/wazero"
+	"github.com/tetratelabs/wazero/internal/gojs/config"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
-func Test_syscall(t *testing.T) {
+func Test_process(t *testing.T) {
 	t.Parallel()
 
-	stdout, stderr, err := compileAndRun(testCtx, "syscall", defaultConfig)
+	require.NoError(t, os.Chdir("/.."))
+	stdout, stderr, err := compileAndRun(testCtx, "process", func(moduleConfig wazero.ModuleConfig) (wazero.ModuleConfig, *config.Config) {
+		return defaultConfig(moduleConfig.WithFS(testFS))
+	})
 
-	require.EqualError(t, err, `module "" closed with exit_code(0)`)
 	require.Zero(t, stderr)
+	require.EqualError(t, err, `module "" closed with exit_code(0)`)
 	require.Equal(t, `syscall.Getpid()=1
 syscall.Getppid()=0
 syscall.Getuid()=0
@@ -21,5 +27,7 @@ syscall.Geteuid()=0
 syscall.Umask(0077)=0o22
 syscall.Getgroups()=[0]
 os.FindProcess(1).Pid=1
+wd ok
+Not a directory
 `, stdout)
 }
diff --git a/internal/gojs/testdata/fs/main.go b/internal/gojs/testdata/fs/main.go
index 9c07f3dc..410e4a5e 100644
--- a/internal/gojs/testdata/fs/main.go
+++ b/internal/gojs/testdata/fs/main.go
@@ -6,7 +6,6 @@ import (
 	"io"
 	"log"
 	"os"
-	"syscall"
 )
 
 func Main() {
@@ -14,19 +13,6 @@ func Main() {
 }
 
 func testAdHoc() {
-	if wd, err := syscall.Getwd(); err != nil {
-		log.Panicln(err)
-	} else if wd != "/" {
-		log.Panicln("not root")
-	}
-	fmt.Println("wd ok")
-
-	if err := syscall.Chdir("/animals.txt"); err == nil {
-		log.Panicln("shouldn't be able to chdir to file")
-	} else {
-		fmt.Println(err) // should be the textual message of the errno.
-	}
-
 	// Ensure stat works, particularly mode.
 	for _, path := range []string{"sub", "/animals.txt", "animals.txt"} {
 		if stat, err := os.Stat(path); err != nil {
diff --git a/internal/gojs/testdata/main.go b/internal/gojs/testdata/main.go
index a78f0d22..9c66072f 100644
--- a/internal/gojs/testdata/main.go
+++ b/internal/gojs/testdata/main.go
@@ -11,8 +11,8 @@ import (
 	"github.com/tetratelabs/wazero/internal/gojs/testdata/goroutine"
 	"github.com/tetratelabs/wazero/internal/gojs/testdata/http"
 	"github.com/tetratelabs/wazero/internal/gojs/testdata/mem"
+	"github.com/tetratelabs/wazero/internal/gojs/testdata/process"
 	"github.com/tetratelabs/wazero/internal/gojs/testdata/stdio"
-	"github.com/tetratelabs/wazero/internal/gojs/testdata/syscall"
 	"github.com/tetratelabs/wazero/internal/gojs/testdata/testfs"
 	"github.com/tetratelabs/wazero/internal/gojs/testdata/time"
 	"github.com/tetratelabs/wazero/internal/gojs/testdata/writefs"
@@ -29,24 +29,24 @@ func main() {
 		os.Exit(255)
 	case "fs":
 		fs.Main()
-	case "testfs":
-		testfs.Main()
-	case "writefs":
-		writefs.Main()
 	case "gc":
 		gc.Main()
-	case "goroutine":
-		goroutine.Main()
 	case "http":
 		http.Main()
+	case "goroutine":
+		goroutine.Main()
 	case "mem":
 		mem.Main()
+	case "process":
+		process.Main()
 	case "stdio":
 		stdio.Main()
-	case "syscall":
-		syscall.Main()
+	case "testfs":
+		testfs.Main()
 	case "time":
 		time.Main()
+	case "writefs":
+		writefs.Main()
 	default:
 		panic(fmt.Errorf("unsupported arg: %s", os.Args[1]))
 	}
diff --git a/internal/gojs/testdata/process/main.go b/internal/gojs/testdata/process/main.go
new file mode 100644
index 00000000..c85df222
--- /dev/null
+++ b/internal/gojs/testdata/process/main.go
@@ -0,0 +1,64 @@
+// Package process is an integration test of system calls mapped to the
+// JavaScript object "process". e.g. `go.syscall/js.valueCall(process.chdir...`
+package process
+
+import (
+	"fmt"
+	"log"
+	"os"
+	"syscall"
+)
+
+func Main() {
+	fmt.Printf("syscall.Getpid()=%d\n", syscall.Getpid())
+	fmt.Printf("syscall.Getppid()=%d\n", syscall.Getppid())
+	fmt.Printf("syscall.Getuid()=%d\n", syscall.Getuid())
+	fmt.Printf("syscall.Getgid()=%d\n", syscall.Getgid())
+	fmt.Printf("syscall.Geteuid()=%d\n", syscall.Geteuid())
+	fmt.Printf("syscall.Umask(0077)=%O\n", syscall.Umask(0o077))
+	if g, err := syscall.Getgroups(); err != nil {
+		log.Panicln(err)
+	} else {
+		fmt.Printf("syscall.Getgroups()=%v\n", g)
+	}
+
+	pid := syscall.Getpid()
+	if p, err := os.FindProcess(pid); err != nil {
+		log.Panicln(err)
+	} else {
+		fmt.Printf("os.FindProcess(%d).Pid=%d\n", pid, p.Pid)
+	}
+
+	if wd, err := syscall.Getwd(); err != nil {
+		log.Panicln(err)
+	} else if wd != "/" {
+		log.Panicln("not root")
+	}
+	fmt.Println("wd ok")
+
+	dirs := []struct {
+		path, wd string
+	}{
+		{"dir", "/dir"},
+		{".", "/dir"},
+		{"..", "/"},
+		{".", "/"},
+		{"..", "/"},
+	}
+
+	for _, dir := range dirs {
+		if err := syscall.Chdir(dir.path); err != nil {
+			log.Panicln(dir.path, err)
+		} else if wd, err := syscall.Getwd(); err != nil {
+			log.Panicln(dir.path, err)
+		} else if wd != dir.wd {
+			log.Panicf("cd %s: expected wd=%s, but have %s", dir.path, dir.wd, wd)
+		}
+	}
+
+	if err := syscall.Chdir("/animals.txt"); err == nil {
+		log.Panicln("shouldn't be able to chdir to file")
+	} else {
+		fmt.Println(err) // should be the textual message of the errno.
+	}
+}
diff --git a/internal/gojs/testdata/syscall/main.go b/internal/gojs/testdata/syscall/main.go
deleted file mode 100644
index 2c593f12..00000000
--- a/internal/gojs/testdata/syscall/main.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package syscall
-
-import (
-	"fmt"
-	"log"
-	"os"
-	"syscall"
-)
-
-func Main() {
-	fmt.Printf("syscall.Getpid()=%d\n", syscall.Getpid())
-	fmt.Printf("syscall.Getppid()=%d\n", syscall.Getppid())
-	fmt.Printf("syscall.Getuid()=%d\n", syscall.Getuid())
-	fmt.Printf("syscall.Getgid()=%d\n", syscall.Getgid())
-	fmt.Printf("syscall.Geteuid()=%d\n", syscall.Geteuid())
-	fmt.Printf("syscall.Umask(0077)=%O\n", syscall.Umask(0o077))
-	if g, err := syscall.Getgroups(); err != nil {
-		log.Panicln(err)
-	} else {
-		fmt.Printf("syscall.Getgroups()=%v\n", g)
-	}
-
-	pid := syscall.Getpid()
-	if p, err := os.FindProcess(pid); err != nil {
-		log.Panicln(err)
-	} else {
-		fmt.Printf("os.FindProcess(%d).Pid=%d\n", pid, p.Pid)
-	}
-}
diff --git a/internal/gojs/util/util_test.go b/internal/gojs/util/util_test.go
index e3027872..db02beb6 100644
--- a/internal/gojs/util/util_test.go
+++ b/internal/gojs/util/util_test.go
@@ -1,6 +1,7 @@
 package util
 
 import (
+	"fmt"
 	"testing"
 
 	"github.com/tetratelabs/wazero/internal/gojs/custom"
@@ -73,3 +74,39 @@ func TestMustRead(t *testing.T) {
 		})
 	}
 }
+
+func TestResolvePath(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		cwd, path string
+		expected  string
+	}{
+		{cwd: "/", path: ".", expected: "/"},
+		{cwd: "/", path: "/", expected: "/"},
+		{cwd: "/", path: "..", expected: "/"},
+		{cwd: "/", path: "a", expected: "/a"},
+		{cwd: "/", path: "/a", expected: "/a"},
+		{cwd: "/", path: "./a/", expected: "/a/"}, // retain trailing slash
+		{cwd: "/", path: "./a/.", expected: "/a"},
+		{cwd: "/", path: "a/.", expected: "/a"},
+		{cwd: "/a", path: "/..", expected: "/"},
+		{cwd: "/a", path: "/", expected: "/"},
+		{cwd: "/a", path: "b", expected: "/a/b"},
+		{cwd: "/a", path: "/b", expected: "/b"},
+		{cwd: "/a", path: "/b/", expected: "/b/"}, // retain trailing slash
+		{cwd: "/a", path: "./b/.", expected: "/a/b"},
+		{cwd: "/a/b", path: ".", expected: "/a/b"},
+		{cwd: "/a/b", path: "../.", expected: "/a"},
+		{cwd: "/a/b", path: "../..", expected: "/"},
+		{cwd: "/a/b", path: "../../..", expected: "/"},
+	}
+
+	for _, tt := range tests {
+		tc := tt
+
+		t.Run(fmt.Sprintf("%s,%s", tc.cwd, tc.path), func(t *testing.T) {
+			require.Equal(t, tc.expected, ResolvePath(tc.cwd, tc.path))
+		})
+	}
+}
diff --git a/internal/platform/stat_test.go b/internal/platform/stat_test.go
index 731985c8..57e016d0 100644
--- a/internal/platform/stat_test.go
+++ b/internal/platform/stat_test.go
@@ -315,7 +315,8 @@ func TestStatFile_dev_inode(t *testing.T) {
 }
 
 func requireDirectoryDevIno(t *testing.T, st Stat_t) {
-	// windows before go 1.20 has trouble reading the inode information on directories.
+	// windows before go 1.20 has trouble reading the inode information on
+	// directories.
 	if runtime.GOOS != "windows" || IsGo120 {
 		require.NotEqual(t, uint64(0), st.Dev)
 		require.NotEqual(t, uint64(0), st.Ino)
@@ -324,3 +325,57 @@ func requireDirectoryDevIno(t *testing.T, st Stat_t) {
 		require.Zero(t, st.Ino)
 	}
 }
+
+// TestStat_uid_gid is similar to os.TestChown
+func TestStat_uid_gid(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("windows")
+	}
+
+	// We don't attempt changing the uid of a file, as only root can do that.
+	// Also, this isn't a test of chown. The main goal here is to read-back
+	// the uid, gid, both of which are zero if run as root.
+	uid := uint32(os.Getuid())
+	gid := uint32(os.Getgid())
+
+	t.Run("Stat", func(t *testing.T) {
+		tmpDir := t.TempDir()
+		dir := path.Join(tmpDir, "dir")
+		require.NoError(t, os.Mkdir(dir, 0o0700))
+		require.NoError(t, chgid(dir, gid))
+
+		var st Stat_t
+		require.NoError(t, Stat(dir, &st))
+		require.Equal(t, uid, st.Uid)
+		require.Equal(t, gid, st.Gid)
+	})
+
+	t.Run("LStat", func(t *testing.T) {
+		tmpDir := t.TempDir()
+		link := path.Join(tmpDir, "link")
+		require.NoError(t, os.Symlink(tmpDir, link))
+		require.NoError(t, chgid(link, gid))
+
+		var st Stat_t
+		require.NoError(t, Lstat(link, &st))
+		require.Equal(t, uid, st.Uid)
+		require.Equal(t, gid, st.Gid)
+	})
+
+	t.Run("StatFile", func(t *testing.T) {
+		tmpDir := t.TempDir()
+		file := path.Join(tmpDir, "file")
+		require.NoError(t, os.WriteFile(file, nil, 0o0600))
+		require.NoError(t, chgid(file, gid))
+
+		var st Stat_t
+		require.NoError(t, Lstat(file, &st))
+		require.Equal(t, uid, st.Uid)
+		require.Equal(t, gid, st.Gid)
+	})
+}
+
+func chgid(path string, gid uint32) error {
+	// Note: In Chown, -1 is means leave the uid alone
+	return Chown(path, -1, int(gid))
+}