Repository: tetratelabs/wazero
Pull request #348 (2022-03-08 11:51:39)

WASI: Implement fd_seek
This PR implements `fd_seek` API.

codefromthecrypt: PS hopefully the other interfaces needed for fs.FS will complete soon, then you can use go's `fstest.TestFS` to test it in addition to unit tests meanwhile.

However, new functions until that's the case should have unit tests. You'll notice a lot of similarity between go's MapFS and our memFile, so looking at code and tests around that may help (ack it mainly punts to `fstest.TestFS`)

Patch:
diff --git a/internal/wasi/fs.go b/internal/wasi/fs.go
index d257fc9c..95d0682c 100644
--- a/internal/wasi/fs.go
+++ b/internal/wasi/fs.go
@@ -1,7 +1,8 @@
 package internalwasi
 
 import (
-	"bytes"
+	"fmt"
+	"io"
 	"io/fs"
 	"os"
 	"runtime"
@@ -65,7 +66,7 @@ func (m *MemFS) OpenWASI(dirFlags uint32, path string, oFlags uint32, fsRights,
 		}
 	}
 
-	ret := &memFile{buf: bytes.NewBuffer(buf)}
+	ret := &memFile{buf: buf}
 
 	if fsRights&wasi.R_FD_WRITE != 0 {
 		ret.flush = func(bts []byte) {
@@ -76,22 +77,51 @@ func (m *MemFS) OpenWASI(dirFlags uint32, path string, oFlags uint32, fsRights,
 	return ret, nil
 }
 
+// memFile implements wasi.File
 type memFile struct {
-	buf   *bytes.Buffer
-	flush func(bts []byte)
+	buf    []byte
+	offset int64
+	flush  func(bts []byte)
 }
 
+// Read implements io.Reader
 func (f *memFile) Read(p []byte) (int, error) {
-	return f.buf.Read(p)
+	// In memFile, the end of the buffer is the end of the file.
+	if f.offset == int64(len(f.buf)) {
+		return 0, io.EOF
+	}
+	nread := copy(p, f.buf[f.offset:])
+	f.offset += int64(nread)
+	return nread, nil
 }
 
+// Write implements io.Writer
 func (f *memFile) Write(p []byte) (int, error) {
-	return f.buf.Write(p)
+	nwritten := copy(f.buf[f.offset:], p)
+	f.buf = append(f.buf, p[nwritten:]...)
+	f.offset += int64(len(p))
+	return len(p), nil
+}
+
+// Seek implements io.Seeker
+func (f *memFile) Seek(offset int64, whence int) (int64, error) {
+	switch whence {
+	case io.SeekStart:
+		f.offset = offset
+	case io.SeekCurrent:
+		f.offset += offset
+	case io.SeekEnd:
+		f.offset = int64(len(f.buf)) + offset
+	default:
+		return 0, fmt.Errorf("invalid whence: %d", whence)
+	}
+	return f.offset, nil
 }
 
+// Close implements io.Closer
 func (f *memFile) Close() error {
 	if f.flush != nil {
-		f.flush(f.buf.Bytes())
+		f.flush(f.buf)
 	}
 	return nil
 }
diff --git a/internal/wasi/wasi.go b/internal/wasi/wasi.go
index 04b302c8..ec0169f8 100644
--- a/internal/wasi/wasi.go
+++ b/internal/wasi/wasi.go
@@ -741,7 +741,37 @@ type SnapshotPreview1 interface {
 	// FdRenumber is the WASI function named FunctionFdRenumber
 	FdRenumber(ctx wasm.Module, fd, to uint32) wasi.Errno
 
-	// FdSeek is the WASI function named FunctionFdSeek
+	// FdSeek is the WASI function to move the offset of a file descriptor.
+	//
+	// * fd: the file descriptor to move the offset of
+	// * offset: the signed int64, which is encoded as uint64, input argument to `whence`, which results in a new offset
+	// * whence: the operator that creates the new offset, given `offset` bytes
+	//   * If io.SeekStart, new offset == `offset`.
+	//   * If io.SeekCurrent, new offset == existing offset + `offset`.
+	//   * If io.SeekEnd, new offset == file size of `fd` + `offset`.
+	// * resultNewoffset: the offset in `ctx.Memory` to write the new offset to, relative to start of the file
+	//
+	// The wasi.Errno returned is wasi.ErrnoSuccess except the following error conditions:
+	// * wasi.ErrnoBadf - if `fd` is invalid
+	// * wasi.ErrnoFault - if `resultNewoffset` is an invalid offset in `ctx.Memory` due to the memory constraint
+	// * wasi.ErrnoInval - if `whence` is an invalid value
+	// * wasi.ErrnoIo - if other error happens during the operation of the underying file system
+	//
+	// For example, if fd 3 is a file with offset 0, and
+	//   parameters fd=3, offset=4, whence=0 (=io.SeekStart), resultNewOffset=1,
+	//   this function writes the below to `ctx.Memory`:
+	//
+	//                           uint64le
+	//                    +--------------------+
+	//                    |                    |
+	//          []byte{?, 4, 0, 0, 0, 0, 0, 0, 0, ? }
+	//  resultNewoffset --^
+	//
+	// See io.Seeker
+	// Note: ImportFdSeek shows this signature in the WebAssembly 1.0 (20191205) Text Format.
+	// Note: This is similar to `lseek` in POSIX.
+	// See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#fd_seek
+	// See https://linux.die.net/man/3/lseek
 	FdSeek(ctx wasm.Module, fd uint32, offset uint64, whence uint32, resultNewoffset uint32) wasi.Errno
 
 	// FdSync is the WASI function named FunctionFdSync
@@ -1218,7 +1248,24 @@ func (a *wasiAPI) FdRenumber(ctx wasm.Module, fd, to uint32) wasi.Errno {
 
 // FdSeek implements SnapshotPreview1.FdSeek
 func (a *wasiAPI) FdSeek(ctx wasm.Module, fd uint32, offset uint64, whence uint32, resultNewoffset uint32) wasi.Errno {
-	return wasi.ErrnoNosys // stubbed for GrainLang per #271
+	f, ok := a.opened[fd]
+	if !ok || f.file == nil {
+		return wasi.ErrnoBadf
+	}
+
+	if whence > io.SeekEnd /* exceeds the largest valid whence */ {
+		return wasi.ErrnoInval
+	}
+	newOffst, err := f.file.Seek(int64(offset), int(whence))
+	if err != nil {
+		return wasi.ErrnoIo
+	}
+
+	if !ctx.Memory().WriteUint32Le(resultNewoffset, uint32(newOffst)) {
+		return wasi.ErrnoFault
+	}
+
+	return wasi.ErrnoSuccess
 }
 
 // FdSync implements SnapshotPreview1.FdSync
diff --git a/wasi/wasi.go b/wasi/wasi.go
index 5b07c0b0..a72a70e3 100644
--- a/wasi/wasi.go
+++ b/wasi/wasi.go
@@ -1,7 +1,10 @@
 // Package wasi includes constants and interfaces used by both public and internal APIs.
 package wasi
 
-import "fmt"
+import (
+	"fmt"
+	"io"
+)
 
 const (
 	// ModuleSnapshotPreview1 is the module name WASI functions are exported into
@@ -28,10 +31,12 @@ const (
 	R_FD_WRITE
 )
 
+// File combines file I/O interfaces supported by ModuleSnapshotPreview1.
 type File interface {
-	Read([]byte) (int, error)
-	Write([]byte) (int, error)
-	Close() error
+	io.Reader
+	io.Writer
+	io.Seeker
+	io.Closer
 }
 
 // FS is an interface for a preopened directory.


Test patch:
diff --git a/internal/wasi/fs_test.go b/internal/wasi/fs_test.go
new file mode 100644
index 00000000..53faa887
--- /dev/null
+++ b/internal/wasi/fs_test.go
@@ -0,0 +1,21 @@
+package internalwasi
+
+import (
+	"testing"
+	"testing/iotest"
+
+	"github.com/stretchr/testify/require"
+)
+
+// TestMemFile_Read_Seek tests the behavior of Read and Seek by iotest.TestReader.
+// See iotest.TestReader
+func TestMemFile_Read_Seek(t *testing.T) {
+	expectedFileContent := []byte("wazero") // arbitrary contents
+	memFile := &memFile{
+		buf: expectedFileContent,
+	}
+	// TestReader tests that io.Reader correctly reads the expected contents.
+	// It also tests io.Seeker when it's implemented, which memFile does.
+	err := iotest.TestReader(memFile, expectedFileContent)
+	require.NoError(t, err)
+}
diff --git a/internal/wasi/wasi_test.go b/internal/wasi/wasi_test.go
index 77ad01ae..8e129c88 100644
--- a/internal/wasi/wasi_test.go
+++ b/internal/wasi/wasi_test.go
@@ -1,7 +1,6 @@
 package internalwasi
 
 import (
-	"bytes"
 	"context"
 	_ "embed"
 	"encoding/binary"
@@ -1016,19 +1015,149 @@ func TestSnapshotPreview1_FdRenumber(t *testing.T) {
 	})
 }
 
-// TestSnapshotPreview1_FdSeek only tests it is stubbed for GrainLang per #271
 func TestSnapshotPreview1_FdSeek(t *testing.T) {
-	mod, fn := instantiateModule(t, FunctionFdSeek, ImportFdSeek, moduleName)
+	fd := uint32(3)              // arbitrary fd after 0, 1, and 2, that are stdin/out/err
+	resultNewoffset := uint32(1) // arbitrary offset in `ctx.Memory` for the new offset value
 
-	t.Run("SnapshotPreview1.FdSeek", func(t *testing.T) {
-		require.Equal(t, wasi.ErrnoNosys, NewAPI().FdSeek(mod, 0, 0, 0, 0))
+	var api *wasiAPI
+	mod, fn := instantiateModule(t, FunctionFdSeek, ImportFdSeek, moduleName, func(a *wasiAPI) {
+		api = a // for later tests
 	})
+	file, memFS := createFile(t, "test_path", []byte("wazero")) // arbitrary non-empty contents
+	api.opened[fd] = fileEntry{
+		path:    "test_path",
+		fileSys: memFS,
+		file:    file,
+	}
 
-	t.Run(FunctionFdSeek, func(t *testing.T) {
-		results, err := fn.Call(nil, 0, 0, 0, 0)
-		require.NoError(t, err)
-		require.Equal(t, wasi.ErrnoNosys, wasi.Errno(results[0])) // cast because results are always uint64
+	// TestSnapshotPreview1_FdSeek uses a matrix because setting up test files is complicated and has to be clean each time.
+	type fdSeekFn func(ctx publicwasm.Module, fd uint32, offset uint64, whence, resultNewOffset uint32) wasi.Errno
+	seekFns := []struct {
+		name   string
+		fdSeek func() fdSeekFn
+	}{
+		{"SnapshotPreview1.FdSeek", func() fdSeekFn {
+			return api.FdSeek
+		}},
+		{FunctionFdSeek, func() fdSeekFn {
+			return func(ctx publicwasm.Module, fd uint32, offset uint64, whence, resultNewoffset uint32) wasi.Errno {
+				ret, err := fn.Call(mod, uint64(fd), uint64(offset), uint64(whence), uint64(resultNewoffset))
+				require.NoError(t, err)
+				return wasi.Errno(ret[0])
+			}
+		}},
+	}
+
+	tests := []struct {
+		name           string
+		offset         int64
+		whence         int
+		expectedOffset int64
+		expectedMemory []byte
+	}{
+		{
+			name:           "SeekStart",
+			offset:         4, // arbitrary offset
+			whence:         io.SeekStart,
+			expectedOffset: 4, // = offset
+			expectedMemory: []byte{
+				'?',        // resultNewoffset is after this
+				4, 0, 0, 0, // = expectedOffset
+				'?',
+			},
+		},
+		{
+			name:           "SeekCurrent",
+			offset:         1, // arbitrary offset
+			whence:         io.SeekCurrent,
+			expectedOffset: 2, // = 1 (the initial offset of the test file) + 1 (offset)
+			expectedMemory: []byte{
+				'?',        // resultNewoffset is after this
+				2, 0, 0, 0, // = expectedOffset
+				'?',
+			},
+		},
+		{
+			name:           "SeekEnd",
+			offset:         -1, // arbitrary offset, note that offset can be negative
+			whence:         io.SeekEnd,
+			expectedOffset: 5, // = 6 (the size of the test file with content "wazero") + -1 (offset)
+			expectedMemory: []byte{
+				'?',        // resultNewoffset is after this
+				5, 0, 0, 0, // = expectedOffset
+				'?',
+			},
+		},
+	}
+
+	for _, seekFn := range seekFns {
+		sf := seekFn
+		t.Run(sf.name, func(t *testing.T) {
+			for _, tt := range tests {
+				tc := tt
+				t.Run(tc.name, func(t *testing.T) {
+					maskMemory(t, mod, len(tc.expectedMemory))
+					file.offset = 1 // set the initial offset of the file to 1
+
+					errno := sf.fdSeek()(mod, fd, uint64(tc.offset), uint32(tc.whence), resultNewoffset)
+					require.Equal(t, wasi.ErrnoSuccess, errno)
+
+					actual, ok := mod.Memory().Read(0, uint32(len(tc.expectedMemory)))
+					require.True(t, ok)
+					require.Equal(t, tc.expectedMemory, actual)
+
+					require.Equal(t, tc.expectedOffset, file.offset) // test that the offset of file is acutally updated.
+				})
+			}
+		})
+	}
+}
+
+func TestSnapshotPreview1_FdSeek_Errors(t *testing.T) {
+	validFD := uint64(3)                                        // arbitrary valid fd after 0, 1, and 2, that are stdin/out/err
+	file, memFS := createFile(t, "test_path", []byte("wazero")) // arbitrary valid file with non-empty contents
+	mod, fn := instantiateModule(t, FunctionFdSeek, ImportFdSeek, moduleName, func(a *wasiAPI) {
+		a.opened[uint32(validFD)] = fileEntry{
+			path:    "test_path",
+			fileSys: memFS,
+			file:    file,
+		}
 	})
+	memorySize := mod.Memory().Size()
+
+	tests := []struct {
+		name                                string
+		fd, offset, whence, resultNewoffset uint64
+		expectedErrno                       wasi.Errno
+	}{
+		{
+			name:          "invalid fd",
+			fd:            42, // arbitrary invalid fd
+			expectedErrno: wasi.ErrnoBadf,
+		},
+		{
+			name:          "invalid whence",
+			fd:            validFD,
+			whence:        3, // invalid whence, the largest whence io.SeekEnd(2) + 1
+			expectedErrno: wasi.ErrnoInval,
+		},
+		{
+			name:            "out-of-memory writing resultNewoffset",
+			fd:              validFD,
+			resultNewoffset: uint64(memorySize),
+			expectedErrno:   wasi.ErrnoFault,
+		},
+	}
+
+	for _, tt := range tests {
+		tc := tt
+		t.Run(tc.name, func(t *testing.T) {
+			results, err := fn.Call(nil, tc.fd, tc.offset, tc.whence, tc.resultNewoffset)
+			require.NoError(t, err)
+			require.Equal(t, tc.expectedErrno, wasi.Errno(results[0])) // results[0] is the errno
+		})
+	}
+
 }
 
 // TestSnapshotPreview1_FdSync only tests it is stubbed for GrainLang per #271
@@ -1127,7 +1256,7 @@ func TestSnapshotPreview1_FdWrite(t *testing.T) {
 			actual, ok := mod.Memory().Read(0, uint32(len(expectedMemory)))
 			require.True(t, ok)
 			require.Equal(t, expectedMemory, actual)
-			require.Equal(t, []byte("wazero"), file.buf.Bytes()) // verify the file was actually written
+			require.Equal(t, []byte("wazero"), file.buf) // verify the file was actually written
 		})
 	}
 }
@@ -1212,11 +1341,10 @@ func createFile(t *testing.T, path string, contents []byte) (*memFile, *MemFS) {
 	f, err := memFS.OpenWASI(0, path, wasi.O_CREATE|wasi.O_TRUNC, wasi.R_FD_WRITE, 0, 0)
 	require.NoError(t, err)
 
-	if _, err := io.Copy(f, bytes.NewBuffer(contents)); err != nil {
-		require.NoError(t, err)
-	}
+	memFile := f.(*memFile)
+	memFile.buf = append([]byte{}, contents...)
 
-	return f.(*memFile), memFS
+	return memFile, memFS
 }
 
 // TestSnapshotPreview1_PathCreateDirectory only tests it is stubbed for GrainLang per #271