Repository: tetratelabs/wazero
Pull request #1038 (2023-01-16 00:12:30)

fs: implements WriterAtOffset for WASI and gojs
This implements WriterAtOffset, which supports WASI `fd_pwrite` and gojs `fs.write` with offset, which is used to implement `syscall.Pwrite`. I confirmed this passes the corresponding test in wasi-testsuite as well.

Dependent on #1037

codefromthecrypt: rebased to main and added empty test
codefromthecrypt: will fix windows before merge
codefromthecrypt: amazing windows passed

Patch:
diff --git a/imports/wasi_snapshot_preview1/fs.go b/imports/wasi_snapshot_preview1/fs.go
index 2504edd2..94e077e2 100644
--- a/imports/wasi_snapshot_preview1/fs.go
+++ b/imports/wasi_snapshot_preview1/fs.go
@@ -431,13 +431,19 @@ func fdPrestatDirNameFn(_ context.Context, mod api.Module, params []uint64) Errn
 // fdPwrite is the WASI function named FdPwriteName which writes to a file
 // descriptor, without using and updating the file descriptor's offset.
 //
+// Except for handling offset, this implementation is identical to fdWrite.
+//
 // See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#-fd_pwritefd-fd-iovs-ciovec_array-offset-filesize---errno-size
-var fdPwrite = stubFunction(
-	FdPwriteName,
-	[]wasm.ValueType{i32, i32, i32, i64, i32},
+var fdPwrite = newHostFunc(
+	FdPwriteName, fdPwriteFn,
+	[]api.ValueType{i32, i32, i32, i64, i32},
 	"fd", "iovs", "iovs_len", "offset", "result.nwritten",
 )
 
+func fdPwriteFn(_ context.Context, mod api.Module, params []uint64) Errno {
+	return fdWriteOrPwrite(mod, params, true)
+}
+
 // fdRead is the WASI function named FdReadName which reads from a file
 // descriptor.
 //
@@ -1015,17 +1021,28 @@ var fdWrite = newHostFunc(
 )
 
 func fdWriteFn(_ context.Context, mod api.Module, params []uint64) Errno {
+	return fdWriteOrPwrite(mod, params, false)
+}
+
+func fdWriteOrPwrite(mod api.Module, params []uint64, isPwrite bool) Errno {
 	mem := mod.Memory()
 	fsc := mod.(*wasm.CallContext).Sys.FS()
 
 	fd := uint32(params[0])
 	iovs := uint32(params[1])
 	iovsCount := uint32(params[2])
-	resultNwritten := uint32(params[3])
 
-	writer := sys.WriterForFile(fsc, fd)
-	if writer == nil {
+	var resultNwritten uint32
+	var writer io.Writer
+	if f, ok := fsc.LookupFile(fd); !ok {
 		return ErrnoBadf
+	} else if isPwrite {
+		offset := int64(params[3])
+		writer = syscallfs.WriterAtOffset(f.File, offset)
+		resultNwritten = uint32(params[4])
+	} else {
+		writer = f.File.(io.Writer)
+		resultNwritten = uint32(params[3])
 	}
 
 	var err error
diff --git a/internal/gojs/fs.go b/internal/gojs/fs.go
index 64e35e5f..03459915 100644
--- a/internal/gojs/fs.go
+++ b/internal/gojs/fs.go
@@ -319,9 +319,16 @@ func (jsfsWrite) invoke(ctx context.Context, mod api.Module, args ...interface{}
 func syscallWrite(mod api.Module, fd uint32, offset interface{}, p []byte) (n uint32, err error) {
 	fsc := mod.(*wasm.CallContext).Sys.FS()
 
-	if writer := internalsys.WriterForFile(fsc, fd); writer == nil {
+	var writer io.Writer
+	if f, ok := fsc.LookupFile(fd); !ok {
 		err = syscall.EBADF
-	} else if nWritten, e := writer.Write(p); e == nil || e == io.EOF {
+	} else if offset != nil {
+		writer = syscallfs.WriterAtOffset(f.File, toInt64(offset))
+	} else {
+		writer = f.File.(io.Writer)
+	}
+
+	if nWritten, e := writer.Write(p); e == nil || e == io.EOF {
 		// fs_js.go cannot parse io.EOF so coerce it to nil.
 		// See https://github.com/golang/go/issues/43913
 		n = uint32(nWritten)
diff --git a/internal/syscallfs/syscall_windows.go b/internal/syscallfs/syscall_windows.go
index 52a46751..5883feac 100644
--- a/internal/syscallfs/syscall_windows.go
+++ b/internal/syscallfs/syscall_windows.go
@@ -97,8 +97,9 @@ func maybeWrapFile(f file) file {
 	return struct {
 		readFile
 		io.Writer
+		io.WriterAt // for pwrite
 		syncer
-	}{f, &windowsWriter{f}, f}
+	}{f, &windowsWriter{f}, f, f}
 }
 
 // windowsWriter translates error codes not mapped properly by Go.
diff --git a/internal/syscallfs/syscallfs.go b/internal/syscallfs/syscallfs.go
index 6c6c9a5b..2491442b 100644
--- a/internal/syscallfs/syscallfs.go
+++ b/internal/syscallfs/syscallfs.go
@@ -143,6 +143,7 @@ type readFile interface {
 type file interface {
 	readFile
 	io.Writer
+	io.WriterAt // for pwrite
 	syncer
 }
 
@@ -225,3 +226,37 @@ func (rs *seekToOffsetReader) Read(p []byte) (int, error) {
 	rs.offset += int64(n)
 	return n, err
 }
+
+// WriterAtOffset gets an io.Writer from a fs.File that writes to an offset,
+// yet doesn't affect the underlying position. This is used to implement
+// syscall.Pwrite.
+func WriterAtOffset(f fs.File, offset int64) io.Writer {
+	if ret, ok := f.(io.WriterAt); ok {
+		return &writerAtOffset{ret, offset}
+	} else {
+		return enosysWriter{}
+	}
+}
+
+type enosysWriter struct{}
+
+// enosysWriter implements io.Writer
+func (rs enosysWriter) Write([]byte) (n int, err error) {
+	return 0, syscall.ENOSYS
+}
+
+type writerAtOffset struct {
+	r      io.WriterAt
+	offset int64
+}
+
+// Write implements io.Writer
+func (r *writerAtOffset) Write(p []byte) (int, error) {
+	if len(p) == 0 {
+		return 0, nil // less overhead on zero-length writes.
+	}
+
+	n, err := r.r.WriteAt(p, r.offset)
+	r.offset += int64(n)
+	return n, err
+}
diff --git a/site/content/specs.md b/site/content/specs.md
index 040ac997..ab8448ae 100644
--- a/site/content/specs.md
+++ b/site/content/specs.md
@@ -101,7 +101,7 @@ Note: C (via clang) supports the maximum WASI functions due to [wasi-libc][16].
 | fd_pread                |   ✅    |             Zig |
 | fd_prestat_get          |   ✅    | Rust,TinyGo,Zig |
 | fd_prestat_dir_name     |   ✅    | Rust,TinyGo,Zig |
-| fd_pwrite               |   ❌    |                 |
+| fd_pwrite               |   ✅    |        Rust,Zig |
 | fd_read                 |   ✅    | Rust,TinyGo,Zig |
 | fd_readdir              |   ✅    |        Rust,Zig |
 | fd_renumber             |   ❌    |                 |


Test patch:
diff --git a/imports/wasi_snapshot_preview1/fs_test.go b/imports/wasi_snapshot_preview1/fs_test.go
index 15bf411f..33aeb8af 100644
--- a/imports/wasi_snapshot_preview1/fs_test.go
+++ b/imports/wasi_snapshot_preview1/fs_test.go
@@ -919,13 +919,300 @@ func Test_fdPrestatDirName_Errors(t *testing.T) {
 	}
 }
 
-// Test_fdPwrite only tests it is stubbed for GrainLang per #271
 func Test_fdPwrite(t *testing.T) {
-	log := requireErrnoNosys(t, FdPwriteName, 0, 0, 0, 0, 0)
-	require.Equal(t, `
---> wasi_snapshot_preview1.fd_pwrite(fd=0,iovs=0,iovs_len=0,offset=0)
-<-- (nwritten=,errno=ENOSYS)
-`, log)
+	tmpDir := t.TempDir() // open before loop to ensure no locking problems.
+	pathName := "test_path"
+	mod, fd, log, r := requireOpenFile(t, tmpDir, pathName, []byte{}, false)
+	defer r.Close(testCtx)
+
+	iovs := uint32(1) // arbitrary offset
+	initialMemory := []byte{
+		'?',         // `iovs` is after this
+		18, 0, 0, 0, // = iovs[0].offset
+		4, 0, 0, 0, // = iovs[0].length
+		23, 0, 0, 0, // = iovs[1].offset
+		2, 0, 0, 0, // = iovs[1].length
+		'?',
+		'w', 'a', 'z', 'e', // iovs[0].length bytes
+		'?',      // iovs[1].offset is after this
+		'r', 'o', // iovs[1].length bytes
+	}
+
+	iovsCount := uint32(2) // The count of iovs
+	resultNwritten := len(initialMemory) + 1
+
+	tests := []struct {
+		name             string
+		offset           int64
+		expectedMemory   []byte
+		expectedContents string
+		expectedLog      string
+	}{
+		{
+			name:   "offset zero",
+			offset: 0,
+			expectedMemory: append(
+				initialMemory,
+				'?',        // resultNwritten is after this
+				6, 0, 0, 0, // sum(iovs[...].length) == length of "wazero"
+				'?',
+			),
+			expectedContents: "wazero",
+			expectedLog: `
+==> wasi_snapshot_preview1.fd_pwrite(fd=4,iovs=1,iovs_len=2,offset=0)
+<== (nwritten=6,errno=ESUCCESS)
+`,
+		},
+		{
+			name:   "offset 2",
+			offset: 2,
+			expectedMemory: append(
+				initialMemory,
+				'?',        // resultNwritten is after this
+				6, 0, 0, 0, // sum(iovs[...].length) == length of "wazero"
+				'?',
+			),
+			expectedContents: "wawazero", // "wa" from the first test!
+			expectedLog: `
+==> wasi_snapshot_preview1.fd_pwrite(fd=4,iovs=1,iovs_len=2,offset=2)
+<== (nwritten=6,errno=ESUCCESS)
+`,
+		},
+	}
+
+	for _, tt := range tests {
+		tc := tt
+		t.Run(tc.name, func(t *testing.T) {
+			defer log.Reset()
+
+			maskMemory(t, mod, len(tc.expectedMemory))
+
+			ok := mod.Memory().Write(0, initialMemory)
+			require.True(t, ok)
+
+			requireErrno(t, ErrnoSuccess, mod, FdPwriteName, uint64(fd), uint64(iovs), uint64(iovsCount), uint64(tc.offset), uint64(resultNwritten))
+			require.Equal(t, tc.expectedLog, "\n"+log.String())
+
+			actual, ok := mod.Memory().Read(0, uint32(len(tc.expectedMemory)))
+			require.True(t, ok)
+			require.Equal(t, tc.expectedMemory, actual)
+
+			// Ensure the contents were really written
+			b, err := os.ReadFile(path.Join(tmpDir, pathName))
+			require.NoError(t, err)
+			require.Equal(t, tc.expectedContents, string(b))
+		})
+	}
+}
+
+func Test_fdPwrite_offset(t *testing.T) {
+	tmpDir := t.TempDir()
+	pathName := "test_path"
+	mod, fd, log, r := requireOpenFile(t, tmpDir, pathName, []byte{}, false)
+	defer r.Close(testCtx)
+
+	// Do an initial fdPwrite.
+
+	iovs := uint32(1) // arbitrary offset
+	pwriteMemory := []byte{
+		'?',         // `iovs` is after this
+		10, 0, 0, 0, // = iovs[0].offset
+		3, 0, 0, 0, // = iovs[0].length
+		'?',
+		'e', 'r', 'o', // iovs[0].length bytes
+		'?', // resultNwritten is after this
+	}
+	iovsCount := uint32(1) // The count of iovs
+	resultNwritten := len(pwriteMemory) + 4
+
+	expectedMemory := append(
+		pwriteMemory,
+		'?', '?', '?', '?', // resultNwritten is after this
+		3, 0, 0, 0, // sum(iovs[...].length) == length of "ero"
+		'?',
+	)
+
+	maskMemory(t, mod, len(expectedMemory))
+
+	ok := mod.Memory().Write(0, pwriteMemory)
+	require.True(t, ok)
+
+	// Write the last half first, to offset 3
+	requireErrno(t, ErrnoSuccess, mod, FdPwriteName, uint64(fd), uint64(iovs), uint64(iovsCount), 3, uint64(resultNwritten))
+	actual, ok := mod.Memory().Read(0, uint32(len(expectedMemory)))
+	require.True(t, ok)
+	require.Equal(t, expectedMemory, actual)
+
+	// Verify that the fdPwrite didn't affect the fdWrite offset.
+	writeMemory := []byte{
+		'?',         // `iovs` is after this
+		10, 0, 0, 0, // = iovs[0].offset
+		3, 0, 0, 0, // = iovs[0].length
+		'?',
+		'w', 'a', 'z', // iovs[0].length bytes
+		'?', // resultNwritten is after this
+	}
+	expectedMemory = append(
+		writeMemory,
+		'?', '?', '?', '?', // resultNwritten is after this
+		3, 0, 0, 0, // sum(iovs[...].length) == length of "waz"
+		'?',
+	)
+
+	ok = mod.Memory().Write(0, writeMemory)
+	require.True(t, ok)
+
+	requireErrno(t, ErrnoSuccess, mod, FdWriteName, uint64(fd), uint64(iovs), uint64(iovsCount), uint64(resultNwritten))
+	actual, ok = mod.Memory().Read(0, uint32(len(expectedMemory)))
+	require.True(t, ok)
+	require.Equal(t, expectedMemory, actual)
+
+	expectedLog := `
+==> wasi_snapshot_preview1.fd_pwrite(fd=4,iovs=1,iovs_len=1,offset=3)
+<== (nwritten=3,errno=ESUCCESS)
+==> wasi_snapshot_preview1.fd_write(fd=4,iovs=1,iovs_len=1)
+<== (nwritten=3,errno=ESUCCESS)
+`
+	require.Equal(t, expectedLog, "\n"+log.String())
+
+	// Ensure the contents were really written
+	b, err := os.ReadFile(path.Join(tmpDir, pathName))
+	require.NoError(t, err)
+	require.Equal(t, "wazero", string(b))
+}
+
+func Test_fdPwrite_Errors(t *testing.T) {
+	tmpDir := t.TempDir() // open before loop to ensure no locking problems.
+	pathName := "test_path"
+	mod, fd, log, r := requireOpenFile(t, tmpDir, pathName, []byte{}, false)
+	defer r.Close(testCtx)
+
+	tests := []struct {
+		name                                string
+		fd, iovs, iovsCount, resultNwritten uint32
+		offset                              int64
+		memory                              []byte
+		expectedErrno                       Errno
+		expectedLog                         string
+	}{
+		{
+			name:          "invalid fd",
+			fd:            42,                         // arbitrary invalid fd
+			memory:        []byte{'?', '?', '?', '?'}, // pass result.nwritten validation
+			expectedErrno: ErrnoBadf,
+			expectedLog: `
+==> wasi_snapshot_preview1.fd_pwrite(fd=42,iovs=65532,iovs_len=0,offset=0)
+<== (nwritten=,errno=EBADF)
+`,
+		},
+		{
+			name:          "out-of-memory writing iovs[0].offset",
+			fd:            fd,
+			iovs:          1,
+			memory:        []byte{'?'},
+			expectedErrno: ErrnoFault,
+			expectedLog: `
+==> wasi_snapshot_preview1.fd_pwrite(fd=4,iovs=65536,iovs_len=0,offset=0)
+<== (nwritten=,errno=EFAULT)
+`,
+		},
+		{
+			name: "out-of-memory writing iovs[0].length",
+			fd:   fd,
+			iovs: 1, iovsCount: 1,
+			memory: []byte{
+				'?',        // `iovs` is after this
+				9, 0, 0, 0, // = iovs[0].offset
+			},
+			expectedErrno: ErrnoFault,
+			expectedLog: `
+==> wasi_snapshot_preview1.fd_pwrite(fd=4,iovs=65532,iovs_len=1,offset=0)
+<== (nwritten=,errno=EFAULT)
+`,
+		},
+		{
+			name: "iovs[0].offset is outside memory",
+			fd:   fd,
+			iovs: 1, iovsCount: 1,
+			memory: []byte{
+				'?',          // `iovs` is after this
+				0, 0, 0x1, 0, // = iovs[0].offset on the second page
+				1, 0, 0, 0, // = iovs[0].length
+			},
+			expectedErrno: ErrnoFault,
+			expectedLog: `
+==> wasi_snapshot_preview1.fd_pwrite(fd=4,iovs=65528,iovs_len=1,offset=0)
+<== (nwritten=,errno=EFAULT)
+`,
+		},
+		{
+			name: "length to write exceeds memory by 1",
+			fd:   fd,
+			iovs: 1, iovsCount: 1,
+			memory: []byte{
+				'?',        // `iovs` is after this
+				9, 0, 0, 0, // = iovs[0].offset
+				0, 0, 0x1, 0, // = iovs[0].length on the second page
+				'?',
+			},
+			expectedErrno: ErrnoFault,
+			expectedLog: `
+==> wasi_snapshot_preview1.fd_pwrite(fd=4,iovs=65527,iovs_len=1,offset=0)
+<== (nwritten=,errno=EFAULT)
+`,
+		},
+		{
+			name: "resultNwritten offset is outside memory",
+			fd:   fd,
+			iovs: 1, iovsCount: 1,
+			resultNwritten: 10, // 1 past memory
+			memory: []byte{
+				'?',        // `iovs` is after this
+				9, 0, 0, 0, // = iovs[0].offset
+				1, 0, 0, 0, // = iovs[0].length
+				'?',
+			},
+			expectedErrno: ErrnoFault,
+			expectedLog: `
+==> wasi_snapshot_preview1.fd_pwrite(fd=4,iovs=65527,iovs_len=1,offset=0)
+<== (nwritten=,errno=EFAULT)
+`,
+		},
+		{
+			name: "offset negative",
+			fd:   fd,
+			iovs: 1, iovsCount: 1,
+			resultNwritten: 10,
+			memory: []byte{
+				'?',        // `iovs` is after this
+				9, 0, 0, 0, // = iovs[0].offset
+				1, 0, 0, 0, // = iovs[0].length
+				'?',
+				'?', '?', '?', '?',
+			},
+			offset:        int64(-1),
+			expectedErrno: ErrnoIo,
+			expectedLog: `
+==> wasi_snapshot_preview1.fd_pwrite(fd=4,iovs=65523,iovs_len=1,offset=-1)
+<== (nwritten=,errno=EIO)
+`,
+		},
+	}
+
+	for _, tt := range tests {
+		tc := tt
+		t.Run(tc.name, func(t *testing.T) {
+			defer log.Reset()
+
+			offset := uint32(wasm.MemoryPagesToBytesNum(testMemoryPageSize) - uint64(len(tc.memory)))
+
+			memoryWriteOK := mod.Memory().Write(offset, tc.memory)
+			require.True(t, memoryWriteOK)
+
+			requireErrno(t, tc.expectedErrno, mod, FdPwriteName, uint64(tc.fd), uint64(tc.iovs+offset), uint64(tc.iovsCount), uint64(tc.offset), uint64(tc.resultNwritten+offset))
+			require.Equal(t, tc.expectedLog, "\n"+log.String())
+		})
+	}
 }
 
 func Test_fdRead(t *testing.T) {
diff --git a/internal/gojs/testdata/writefs/main.go b/internal/gojs/testdata/writefs/main.go
index 6bf6fa56..2a7a4f16 100644
--- a/internal/gojs/testdata/writefs/main.go
+++ b/internal/gojs/testdata/writefs/main.go
@@ -40,6 +40,31 @@ func Main() {
 		}
 	}
 
+	// Now, test that syscall.WriteAt works
+	f, err := os.OpenFile(file1, os.O_RDWR|os.O_CREATE, 0o600)
+	if err != nil {
+		log.Panicln(err)
+	}
+	defer f.Close()
+
+	// Write segments to the file, noting map iteration isn't ordered.
+	bytes := []byte("wazero")
+	for o, b := range map[int][]byte{3: bytes[3:], 0: bytes[:3]} {
+		n, err := f.WriteAt(b, int64(o))
+		if err != nil {
+			log.Panicln(err)
+		} else if n != 3 {
+			log.Panicln("expected 3, but wrote", n)
+		}
+	}
+
+	// Now, use ReadAt (tested in testfs package) to verify everything wrote!
+	if _, err = f.ReadAt(bytes, 0); err != nil {
+		log.Panicln(err)
+	} else if string(bytes) != "wazero" {
+		log.Panicln("unexpected contents:", string(bytes))
+	}
+
 	// Test removing a non-empty empty directory
 	if err = syscall.Rmdir(dir); err != syscall.ENOTEMPTY {
 		log.Panicln("unexpected error", err)
diff --git a/internal/syscallfs/syscallfs_test.go b/internal/syscallfs/syscallfs_test.go
index 91290a8c..7d6edd65 100644
--- a/internal/syscallfs/syscallfs_test.go
+++ b/internal/syscallfs/syscallfs_test.go
@@ -411,3 +411,129 @@ func TestReaderAtOffset_Unsupported(t *testing.T) {
 	_, err = ra.Read(buf)
 	require.Equal(t, syscall.ENOSYS, err)
 }
+
+func TestWriterAtOffset(t *testing.T) {
+	tmpDir := t.TempDir()
+	dirFS, err := NewDirFS("/", tmpDir)
+	require.NoError(t, err)
+
+	// fs.FS doesn't support writes, and there is no other built-in
+	// implementation except os.File.
+	tests := []struct {
+		name string
+		fs   FS
+	}{
+		{name: "syscallfs.dirFS", fs: dirFS},
+	}
+
+	for _, tc := range tests {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			f, err := tc.fs.OpenFile(readerAtFile, os.O_RDWR|os.O_CREATE, 0o600)
+			require.NoError(t, err)
+			defer f.Close()
+
+			w := f.(io.Writer)
+			wa := WriterAtOffset(f, 6)
+
+			text := "wazero"
+			buf := make([]byte, 3)
+			copy(buf, text[:3])
+
+			requireWrite3 := func(r io.Writer, buf []byte) {
+				n, err := r.Write(buf)
+				require.NoError(t, err)
+				require.Equal(t, 3, n)
+			}
+
+			// The file should work as a writer (base case)
+			requireWrite3(w, buf)
+
+			// The writerAt impl should be able to start from zero also
+			requireWrite3(wa, buf)
+
+			copy(buf, text[3:])
+
+			// If the offset didn't change, the next chars will write after the
+			// first
+			requireWrite3(w, buf)
+
+			// If state was held between writer-at, we expect the same
+			requireWrite3(wa, buf)
+
+			// We should also be able to make another writer-at
+			wa = WriterAtOffset(f, 12)
+			requireWrite3(wa, buf)
+
+			r := ReaderAtOffset(f, 0)
+			b, err := io.ReadAll(r)
+			require.NoError(t, err)
+
+			// We expect to have written the text two and a half times:
+			//  1. io.Write: offset 0
+			//  2. io.WriterAt: offset 6
+			//  3. second io.WriterAt: offset 12, writing "ero"
+			require.Equal(t, text+text+text[3:], string(b))
+		})
+	}
+}
+
+func TestWriterAtOffset_empty(t *testing.T) {
+	tmpDir := t.TempDir()
+	dirFS, err := NewDirFS("/", tmpDir)
+	require.NoError(t, err)
+
+	// fs.FS doesn't support writes, and there is no other built-in
+	// implementation except os.File.
+	tests := []struct {
+		name string
+		fs   FS
+	}{
+		{name: "syscallfs.dirFS", fs: dirFS},
+	}
+
+	for _, tc := range tests {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			f, err := tc.fs.OpenFile(emptyFile, os.O_RDWR|os.O_CREATE, 0o600)
+			require.NoError(t, err)
+			defer f.Close()
+
+			r := f.(io.Writer)
+			ra := WriterAtOffset(f, 0)
+
+			var emptyBuf []byte
+
+			requireWrite := func(r io.Writer) {
+				n, err := r.Write(emptyBuf)
+				require.NoError(t, err)
+				require.Equal(t, 0, n) // file is empty
+			}
+
+			// The file should work as a writer (base case)
+			requireWrite(r)
+
+			// The writerAt impl should be able to start from zero also
+			requireWrite(ra)
+		})
+	}
+}
+
+func TestWriterAtOffset_Unsupported(t *testing.T) {
+	tmpDir := t.TempDir()
+	dirFS, err := NewDirFS("/", tmpDir)
+	require.NoError(t, err)
+
+	f, err := dirFS.OpenFile(readerAtFile, os.O_RDWR|os.O_CREATE, 0o600)
+	require.NoError(t, err)
+	defer f.Close()
+
+	// mask both io.WriterAt and io.Seeker
+	ra := WriterAtOffset(struct{ fs.File }{f}, 0)
+
+	buf := make([]byte, 3)
+	_, err = ra.Write(buf)
+	require.Equal(t, syscall.ENOSYS, err)
+}