1package sys
2
3import (
4 "io"
5 "io/fs"
6 "net"
7
8 "github.com/tetratelabs/wazero/experimental/sys"
9 "github.com/tetratelabs/wazero/internal/descriptor"
10 "github.com/tetratelabs/wazero/internal/fsapi"
11 socketapi "github.com/tetratelabs/wazero/internal/sock"
12 "github.com/tetratelabs/wazero/internal/sysfs"
13)
14
15const (
16 FdStdin int32 = iota
17 FdStdout
18 FdStderr
19 // FdPreopen is the file descriptor of the first pre-opened directory.
20 //
21 // # Why file descriptor 3?
22 //
23 // While not specified, the most common WASI implementation, wasi-libc,
24 // expects POSIX style file descriptor allocation, where the lowest
25 // available number is used to open the next file. Since 1 and 2 are taken
26 // by stdout and stderr, the next is 3.
27 // - https://github.com/WebAssembly/WASI/issues/122
28 // - https://pubs.opengroup.org/onlinepubs/9699919799/functions/V2_chap02.html#tag_15_14
29 // - https://github.com/WebAssembly/wasi-libc/blob/wasi-sdk-16/libc-bottom-half/sources/preopens.c#L215
30 FdPreopen
31)
32
33const modeDevice = fs.ModeDevice | 0o640
34
35// FileEntry maps a path to an open file in a file system.
36type FileEntry struct {
37 // Name is the name of the directory up to its pre-open, or the pre-open
38 // name itself when IsPreopen.
39 //
40 // # Notes
41 //
42 // - This can drift on rename.
43 // - This relates to the guest path, which is not the real file path
44 // except if the entire host filesystem was made available.
45 Name string
46
47 // IsPreopen is a directory that is lazily opened.
48 IsPreopen bool
49
50 // FS is the filesystem associated with the pre-open.
51 FS sys.FS
52
53 // File is always non-nil.
54 File fsapi.File
55
56 // direntCache is nil until DirentCache was called.
57 direntCache *DirentCache
58}
59
60// DirentCache gets or creates a DirentCache for this file or returns an error.
61//
62// # Errors
63//
64// A zero sys.Errno is success. The below are expected otherwise:
65// - sys.ENOSYS: the implementation does not support this function.
66// - sys.EBADF: the dir was closed or not readable.
67// - sys.ENOTDIR: the file was not a directory.
68//
69// # Notes
70//
71// - See /RATIONALE.md for design notes.
72func (f *FileEntry) DirentCache() (*DirentCache, sys.Errno) {
73 if dir := f.direntCache; dir != nil {
74 return dir, 0
75 }
76
77 // Require the file to be a directory vs a late error on the same.
78 if isDir, errno := f.File.IsDir(); errno != 0 {
79 return nil, errno
80 } else if !isDir {
81 return nil, sys.ENOTDIR
82 }
83
84 // Generate the dotEntries only once.
85 if dotEntries, errno := synthesizeDotEntries(f); errno != 0 {
86 return nil, errno
87 } else {
88 f.direntCache = &DirentCache{f: f.File, dotEntries: dotEntries}
89 }
90
91 return f.direntCache, 0
92}
93
94// DirentCache is a caching abstraction of sys.File Readdir.
95//
96// This is special-cased for "wasi_snapshot_preview1.fd_readdir", and may be
97// unneeded, or require changes, to support preview1 or preview2.
98// - The position of the dirents are serialized as `d_next`. For reasons
99// described below, any may need to be re-read. This accepts any positions
100// in the cache, rather than track the position of the last dirent.
101// - dot entries ("." and "..") must be returned. See /RATIONALE.md for why.
102// - An sys.Dirent Name is variable length, it could exceed memory size and
103// need to be re-read.
104// - Multiple dirents may be returned. It is more efficient to read from the
105// underlying file in bulk vs one-at-a-time.
106//
107// The last results returned by Read are cached, but entries before that
108// position are not. This support re-reading entries that couldn't fit into
109// memory without accidentally caching all entries in a large directory. This
110// approach is sometimes called a sliding window.
111type DirentCache struct {
112 // f is the underlying file
113 f sys.File
114
115 // dotEntries are the "." and ".." entries added when the directory is
116 // initialized.
117 dotEntries []sys.Dirent
118
119 // dirents are the potentially unread directory entries.
120 //
121 // Internal detail: nil is different from zero length. Zero length is an
122 // exhausted directory (eof). nil means the re-read.
123 dirents []sys.Dirent
124
125 // countRead is the total count of dirents read since last rewind.
126 countRead uint64
127
128 // eof is true when the underlying file is at EOF. This avoids re-reading
129 // the directory when it is exhausted. Entires in an exhausted directory
130 // are not visible until it is rewound via calling Read with `pos==0`.
131 eof bool
132}
133
134// synthesizeDotEntries generates a slice of the two elements "." and "..".
135func synthesizeDotEntries(f *FileEntry) ([]sys.Dirent, sys.Errno) {
136 dotIno, errno := f.File.Ino()
137 if errno != 0 {
138 return nil, errno
139 }
140 result := [2]sys.Dirent{}
141 result[0] = sys.Dirent{Name: ".", Ino: dotIno, Type: fs.ModeDir}
142 // See /RATIONALE.md for why we don't attempt to get an inode for ".." and
143 // why in wasi-libc this won't fan-out either.
144 result[1] = sys.Dirent{Name: "..", Ino: 0, Type: fs.ModeDir}
145 return result[:], 0
146}
147
148// exhaustedDirents avoids allocating empty slices.
149var exhaustedDirents = [0]sys.Dirent{}
150
151// Read is similar to and returns the same errors as `Readdir` on sys.File.
152// The main difference is this caches entries returned, resulting in multiple
153// valid positions to read from.
154//
155// When zero, `pos` means rewind to the beginning of this directory. This
156// implies a rewind (Seek to zero on the underlying sys.File), unless the
157// initial entries are still cached.
158//
159// When non-zero, `pos` is the zero based index of all dirents returned since
160// last rewind. Only entries beginning at `pos` are cached for subsequent
161// calls. A non-zero `pos` before the cache returns sys.ENOENT for reasons
162// described on DirentCache documentation.
163//
164// Up to `n` entries are cached and returned. When `n` exceeds the cache, the
165// difference are read from the underlying sys.File via `Readdir`. EOF is
166// when `len(dirents)` returned are less than `n`.
167func (d *DirentCache) Read(pos uint64, n uint32) (dirents []sys.Dirent, errno sys.Errno) {
168 switch {
169 case pos > d.countRead: // farther than read or negative coerced to uint64.
170 return nil, sys.ENOENT
171 case pos == 0 && d.dirents != nil:
172 // Rewind if we have already read entries. This allows us to see new
173 // entries added after the directory was opened.
174 if _, errno = d.f.Seek(0, io.SeekStart); errno != 0 {
175 return
176 }
177 d.dirents = nil // dump cache
178 d.countRead = 0
179 }
180
181 if n == 0 {
182 return // special case no entries.
183 }
184
185 if d.dirents == nil {
186 // Always populate dot entries, which makes min len(dirents) == 2.
187 d.dirents = d.dotEntries
188 d.countRead = 2
189 d.eof = false
190
191 if countToRead := int(n - 2); countToRead <= 0 {
192 return
193 } else if dirents, errno = d.f.Readdir(countToRead); errno != 0 {
194 return
195 } else if countRead := len(dirents); countRead > 0 {
196 d.eof = countRead < countToRead
197 d.dirents = append(d.dotEntries, dirents...)
198 d.countRead += uint64(countRead)
199 }
200
201 return d.cachedDirents(n), 0
202 }
203
204 // Reset our cache to the first entry being read.
205 cacheStart := d.countRead - uint64(len(d.dirents))
206 if pos < cacheStart {
207 // We don't currently allow reads before our cache because Seek(0) is
208 // the only portable way. Doing otherwise requires skipping, which we
209 // won't do unless wasi-testsuite starts requiring it. Implementing
210 // this would allow re-reading a large directory, so care would be
211 // needed to not buffer the entire directory in memory while skipping.
212 errno = sys.ENOENT
213 return
214 } else if posInCache := pos - cacheStart; posInCache != 0 {
215 if uint64(len(d.dirents)) == posInCache {
216 // Avoid allocation re-slicing to zero length.
217 d.dirents = exhaustedDirents[:]
218 } else {
219 d.dirents = d.dirents[posInCache:]
220 }
221 }
222
223 // See if we need more entries.
224 if countToRead := int(n) - len(d.dirents); countToRead > 0 && !d.eof {
225 // Try to read more, which could fail.
226 if dirents, errno = d.f.Readdir(countToRead); errno != 0 {
227 return
228 }
229
230 // Append the next read entries if we weren't at EOF.
231 if countRead := len(dirents); countRead > 0 {
232 d.eof = countRead < countToRead
233 d.dirents = append(d.dirents, dirents...)
234 d.countRead += uint64(countRead)
235 }
236 }
237
238 return d.cachedDirents(n), 0
239}
240
241// cachedDirents returns up to `n` dirents from the cache.
242func (d *DirentCache) cachedDirents(n uint32) []sys.Dirent {
243 direntCount := uint32(len(d.dirents))
244 switch {
245 case direntCount == 0:
246 return nil
247 case direntCount > n:
248 return d.dirents[:n]
249 }
250 return d.dirents
251}
252
253type FSContext struct {
254 // openedFiles is a map of file descriptor numbers (>=FdPreopen) to open files
255 // (or directories) and defaults to empty.
256 // TODO: This is unguarded, so not goroutine-safe!
257 openedFiles FileTable
258}
259
260// FileTable is a specialization of the descriptor.Table type used to map file
261// descriptors to file entries.
262type FileTable = descriptor.Table[int32, *FileEntry]
263
264// LookupFile returns a file if it is in the table.
265func (c *FSContext) LookupFile(fd int32) (*FileEntry, bool) {
266 return c.openedFiles.Lookup(fd)
267}
268
269// OpenFile opens the file into the table and returns its file descriptor.
270// The result must be closed by CloseFile or Close.
271func (c *FSContext) OpenFile(fs sys.FS, path string, flag sys.Oflag, perm fs.FileMode) (int32, sys.Errno) {
272 if f, errno := fs.OpenFile(path, flag, perm); errno != 0 {
273 return 0, errno
274 } else {
275 fe := &FileEntry{FS: fs, File: fsapi.Adapt(f)}
276 if path == "/" || path == "." {
277 fe.Name = ""
278 } else {
279 fe.Name = path
280 }
281 if newFD, ok := c.openedFiles.Insert(fe); !ok {
282 return 0, sys.EBADF
283 } else {
284 return newFD, 0
285 }
286 }
287}
288
289// Renumber assigns the file pointed by the descriptor `from` to `to`.
290func (c *FSContext) Renumber(from, to int32) sys.Errno {
291 fromFile, ok := c.openedFiles.Lookup(from)
292 if !ok || to < 0 {
293 return sys.EBADF
294 } else if fromFile.IsPreopen {
295 return sys.ENOTSUP
296 }
297
298 // If toFile is already open, we close it to prevent windows lock issues.
299 //
300 // The doc is unclear and other implementations do nothing for already-opened To FDs.
301 // https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#-fd_renumberfd-fd-to-fd---errno
302 // https://github.com/bytecodealliance/wasmtime/blob/main/crates/wasi-common/src/snapshots/preview_1.rs#L531-L546
303 if toFile, ok := c.openedFiles.Lookup(to); ok {
304 if toFile.IsPreopen {
305 return sys.ENOTSUP
306 }
307 _ = toFile.File.Close()
308 }
309
310 c.openedFiles.Delete(from)
311 if !c.openedFiles.InsertAt(fromFile, to) {
312 return sys.EBADF
313 }
314 return 0
315}
316
317// SockAccept accepts a sock.TCPConn into the file table and returns its file
318// descriptor.
319func (c *FSContext) SockAccept(sockFD int32, nonblock bool) (int32, sys.Errno) {
320 var sock socketapi.TCPSock
321 if e, ok := c.LookupFile(sockFD); !ok || !e.IsPreopen {
322 return 0, sys.EBADF // Not a preopen
323 } else if sock, ok = e.File.(socketapi.TCPSock); !ok {
324 return 0, sys.EBADF // Not a sock
325 }
326
327 conn, errno := sock.Accept()
328 if errno != 0 {
329 return 0, errno
330 }
331
332 fe := &FileEntry{File: fsapi.Adapt(conn)}
333
334 if nonblock {
335 if errno = fe.File.SetNonblock(true); errno != 0 {
336 _ = conn.Close()
337 return 0, errno
338 }
339 }
340
341 if newFD, ok := c.openedFiles.Insert(fe); !ok {
342 return 0, sys.EBADF
343 } else {
344 return newFD, 0
345 }
346}
347
348// CloseFile returns any error closing the existing file.
349func (c *FSContext) CloseFile(fd int32) (errno sys.Errno) {
350 f, ok := c.openedFiles.Lookup(fd)
351 if !ok {
352 return sys.EBADF
353 }
354 if errno = f.File.Close(); errno != 0 {
355 return errno
356 }
357 c.openedFiles.Delete(fd)
358 return errno
359}
360
361// Close implements io.Closer
362func (c *FSContext) Close() (err error) {
363 // Close any files opened in this context
364 c.openedFiles.Range(func(fd int32, entry *FileEntry) bool {
365 if errno := entry.File.Close(); errno != 0 {
366 err = errno // This means err returned == the last non-nil error.
367 }
368 return true
369 })
370 // A closed FSContext cannot be reused so clear the state.
371 c.openedFiles = FileTable{}
372 return
373}
374
375// InitFSContext initializes a FSContext with stdio streams and optional
376// pre-opened filesystems and TCP listeners.
377func (c *Context) InitFSContext(
378 stdin io.Reader,
379 stdout, stderr io.Writer,
380 fs []sys.FS, guestPaths []string,
381 tcpListeners []*net.TCPListener,
382) (err error) {
383 inFile, err := stdinFileEntry(stdin)
384 if err != nil {
385 return err
386 }
387 c.fsc.openedFiles.Insert(inFile)
388 outWriter, err := stdioWriterFileEntry("stdout", stdout)
389 if err != nil {
390 return err
391 }
392 c.fsc.openedFiles.Insert(outWriter)
393 errWriter, err := stdioWriterFileEntry("stderr", stderr)
394 if err != nil {
395 return err
396 }
397 c.fsc.openedFiles.Insert(errWriter)
398
399 for i, f := range fs {
400 guestPath := guestPaths[i]
401
402 if StripPrefixesAndTrailingSlash(guestPath) == "" {
403 // Default to bind to '/' when guestPath is effectively empty.
404 guestPath = "/"
405 }
406 c.fsc.openedFiles.Insert(&FileEntry{
407 FS: f,
408 Name: guestPath,
409 IsPreopen: true,
410 File: &lazyDir{fs: f},
411 })
412 }
413
414 for _, tl := range tcpListeners {
415 c.fsc.openedFiles.Insert(&FileEntry{IsPreopen: true, File: fsapi.Adapt(sysfs.NewTCPListenerFile(tl))})
416 }
417 return nil
418}
419
420// StripPrefixesAndTrailingSlash skips any leading "./" or "/" such that the
421// result index begins with another string. A result of "." coerces to the
422// empty string "" because the current directory is handled by the guest.
423//
424// Results are the offset/len pair which is an optimization to avoid re-slicing
425// overhead, as this function is called for every path operation.
426//
427// Note: Relative paths should be handled by the guest, as that's what knows
428// what the current directory is. However, paths that escape the current
429// directory e.g. "../.." have been found in `tinygo test` and this
430// implementation takes care to avoid it.
431func StripPrefixesAndTrailingSlash(path string) string {
432 // strip trailing slashes
433 pathLen := len(path)
434 for ; pathLen > 0 && path[pathLen-1] == '/'; pathLen-- {
435 }
436
437 pathI := 0
438loop:
439 for pathI < pathLen {
440 switch path[pathI] {
441 case '/':
442 pathI++
443 case '.':
444 nextI := pathI + 1
445 if nextI < pathLen && path[nextI] == '/' {
446 pathI = nextI + 1
447 } else if nextI == pathLen {
448 pathI = nextI
449 } else {
450 break loop
451 }
452 default:
453 break loop
454 }
455 }
456 return path[pathI:pathLen]
457}