diff --git a/Sources/WASI/CMakeLists.txt b/Sources/WASI/CMakeLists.txt index 47eb28f57..79aba6361 100644 --- a/Sources/WASI/CMakeLists.txt +++ b/Sources/WASI/CMakeLists.txt @@ -9,6 +9,7 @@ add_wasmkit_library(WASI Platform/PAL/PALErrnoMapping.swift Platform/PAL/PALPoll.swift Platform/PAL/PALSystemClocks.swift + Platform/PAL/PALWindows.swift Platform/SandboxPrimitives/Open.swift Platform/SandboxPrimitives/Readlink.swift Platform/SandboxPrimitives/OpenParent.swift diff --git a/Sources/WASI/FileSystem.swift b/Sources/WASI/FileSystem.swift index f229c7d75..51248346b 100644 --- a/Sources/WASI/FileSystem.swift +++ b/Sources/WASI/FileSystem.swift @@ -74,6 +74,9 @@ extension WASIEntry { func removeDirectory(atPath path: String) throws func removeFile(atPath path: String) throws func symlink(from sourcePath: String, to destPath: String) throws + /// Creates a hard link at `destPath` in `newDir` to the entry at + /// `sourcePath`, without following a symlink there. + func link(from sourcePath: String, toDir newDir: any WASIDir, to destPath: String) throws func rename(from sourcePath: String, toDir newDir: any WASIDir, to destPath: String) throws func readEntries(cookie: WASIAbi.DirCookie) throws -> WASIReaddirEntries func attributes(path: String, symlinkFollow: Bool) throws -> WASIAbi.Filestat @@ -84,6 +87,12 @@ extension WASIEntry { ) throws } +extension WASIDir { + @_spi(WASIPlatform) public func link(from sourcePath: String, toDir newDir: any WASIDir, to destPath: String) throws { + throw WASIAbi.Errno.ENOTSUP + } +} + /// A single directory entry. @_spi(WASIPlatform) public typealias WASIReaddirElement = (dirent: WASIAbi.Dirent, name: String) diff --git a/Sources/WASI/Platform/Directory.swift b/Sources/WASI/Platform/Directory.swift index 378cf31c4..4369f3074 100644 --- a/Sources/WASI/Platform/Directory.swift +++ b/Sources/WASI/Platform/Directory.swift @@ -45,9 +45,7 @@ extension DirEntry: WASIDir, FdWASIEntry { options.insert(.directory) } - if fdflags.contains(.APPEND) { - options.insert(.append) - } + options.formUnion(fdflags.platformOpenOptions) let mode: FileDescriptor.AccessMode switch (accessMode.contains(.read), accessMode.contains(.write)) { @@ -79,17 +77,17 @@ extension DirEntry: WASIDir, FdWASIEntry { atim: WASIAbi.Timestamp, mtim: WASIAbi.Timestamp, fstFlags: WASIAbi.FstFlags, symlinkFollow: Bool ) throws { - let fd = try openFile( - symlinkFollow: symlinkFollow, path: path, - oflags: [], accessMode: .write, fdflags: [] + let (access, modification) = try WASIAbi.Timestamp.platformTimeSpec( + atim: atim, mtim: mtim, fstFlags: fstFlags ) - try withThrowing { - let (access, modification) = try WASIAbi.Timestamp.platformTimeSpec( - atim: atim, mtim: mtim, fstFlags: fstFlags - ) - try fd.setTimes(access: access, modification: modification) - } defer: { - try fd.close() + // Following happens in the sandbox, and `utimensat` never follows a + // symlink in the last component itself. + let result = + symlinkFollow + ? try SandboxPrimitives.openParentFollowingSymlinks(start: fd, path: path) + : try SandboxPrimitives.openParent(start: fd, path: path) + try result.withFields { dir, basename in + try dir.setTimes(at: basename, options: .noFollow, access: access, modification: modification) } } @@ -113,7 +111,31 @@ extension DirEntry: WASIDir, FdWASIEntry { start: fd, path: destPath ) try result.withFields { destDir, destBasename in - try destDir.createSymlink(original: sourcePath, link: destBasename) + try destDir.createSymlink(original: sourcePath, link: destBasename) { + // Look the target up inside the sandbox, relative to the + // directory the link goes in. + guard let (parent, _) = splitParent(path: destPath) else { return false } + let targetPath = parent.isEmpty ? sourcePath : parent.string + "/" + sourcePath + guard + let target = try? SandboxPrimitives.openAt( + start: fd, path: GuestPath(targetPath), mode: .readOnly, options: .directory, permissions: []) + else { return false } + try? target.close() + return true + } + } + } + + func link(from sourcePath: String, toDir newDir: any WASIDir, to destPath: String) throws { + guard let newDir = newDir as? Self else { + throw WASIAbi.Errno.EBADF + } + let sourceResult = try SandboxPrimitives.openParent(start: fd, path: sourcePath) + let destResult = try SandboxPrimitives.openParent(start: newDir.fd, path: destPath) + try sourceResult.withFields { sourceDir, sourceBasename in + try destResult.withFields { destDir, destBasename in + try sourceDir.createHardLink(at: sourceBasename, to: destDir, at: destBasename) + } } } @@ -153,7 +175,6 @@ extension DirEntry: WASIDir, FdWASIEntry { } struct HostDirectoryIterator: WASIReaddirIterator { - let fd: FileDescriptor let stream: FileDescriptor.DirectoryStream var entryIndex: Int @@ -166,7 +187,6 @@ extension DirEntry: WASIDir, FdWASIEntry { let newFd = try fd.open(at: ".", .readOnly) let stream = try newFd.contentsOfDirectory() - self.fd = fd self.entryIndex = 0 self.stream = stream @@ -174,8 +194,9 @@ extension DirEntry: WASIDir, FdWASIEntry { // simply skips everything. let skippedCount = Int(clamping: cookie) while entryIndex < skippedCount { - guard let entry = next() else { break } + guard let entry = stream.next() else { break } _ = try entry.get() + entryIndex += 1 } } @@ -187,13 +208,14 @@ extension DirEntry: WASIDir, FdWASIEntry { return Result(catching: { () -> ReaddirElement in let entry = try entry.get() let name = entry.name - let stat = try fd.attributes(at: name, options: [.noFollow]) let dirent = WASIAbi.Dirent( // We can't use telldir and seekdir because the location data // is valid for only the same dirp but and there is no way to // share dirp among fd_readdir calls. dNext: WASIAbi.DirCookie(entryIndex + 1), - dIno: stat.inode, + // The inode readdir reports, as wasmtime does, rather + // than one `fstatat` per entry. + dIno: entry.inode, dirNameLen: WASIAbi.DirNameLen(name.utf8.count), dType: WASIAbi.FileType(platformFileType: entry.fileType) ) @@ -219,13 +241,14 @@ extension DirEntry: WASIDir, FdWASIEntry { } func attributes(path: String, symlinkFollow: Bool) throws -> WASIAbi.Filestat { - var options: FileDescriptor.AtOptions = [] - if !symlinkFollow { - options.insert(.noFollow) - } - let result = try SandboxPrimitives.openParent(start: fd, path: path) + // Following happens in the sandbox, never in the host's `fstatat`, + // which would follow a symlink pointing out of the sandbox. + let result = + symlinkFollow + ? try SandboxPrimitives.openParentFollowingSymlinks(start: fd, path: path) + : try SandboxPrimitives.openParent(start: fd, path: path) return try result.withFields { dir, basename in - let attributes = try dir.attributes(at: basename, options: options) + let attributes = try dir.attributes(at: basename, options: .noFollow) return WASIAbi.Filestat(stat: attributes) } diff --git a/Sources/WASI/Platform/PAL/PALErrnoMapping.swift b/Sources/WASI/Platform/PAL/PALErrnoMapping.swift index 488f6923f..540e8982f 100644 --- a/Sources/WASI/Platform/PAL/PALErrnoMapping.swift +++ b/Sources/WASI/Platform/PAL/PALErrnoMapping.swift @@ -199,6 +199,15 @@ private func _mapWindowsError(_ code: UInt32) -> WASIAbi.Errno? { return .ENOSPC case ERROR_DIR_NOT_EMPTY: return .ENOTEMPTY + case ERROR_DIRECTORY: + return .ENOTDIR + case ERROR_INVALID_NAME: + return .ENOENT + case ERROR_CANT_RESOLVE_FILENAME, + ERROR_STOPPED_ON_SYMLINK: + return .ELOOP + case ERROR_PRIVILEGE_NOT_HELD: + return .EPERM case ERROR_WAIT_NO_CHILDREN, ERROR_CHILD_NOT_COMPLETE: return .ECHILD diff --git a/Sources/WASI/Platform/PAL/PALFileDescriptor.swift b/Sources/WASI/Platform/PAL/PALFileDescriptor.swift index 3af40adbd..08c9878b8 100644 --- a/Sources/WASI/Platform/PAL/PALFileDescriptor.swift +++ b/Sources/WASI/Platform/PAL/PALFileDescriptor.swift @@ -126,7 +126,9 @@ struct FileDescriptor: Sendable, Hashable { /// Opens a host directory for use as a WASI preopen. static func openPreopenDirectory(_ path: String) throws -> FileDescriptor { - #if os(Windows) || os(WASI) + #if os(Windows) + return try WindowsFileSystem.openPreopenDirectory(path) + #elseif os(WASI) return try open(path, .readWrite) #elseif canImport(Darwin) || canImport(Glibc) || canImport(Musl) || canImport(Android) let fd = try valueOrErrno { @@ -142,6 +144,7 @@ struct FileDescriptor: Sendable, Hashable { func close() throws { #if os(Windows) + WindowsFileSystem.openOptions.set([], for: self) try valueOrErrno(retryOnInterrupt: false) { _close(rawValue) } #elseif canImport(Darwin) || canImport(Glibc) || canImport(Musl) || canImport(Android) || os(WASI) try valueOrErrno(retryOnInterrupt: false) { _pal_close(rawValue) } @@ -164,6 +167,10 @@ struct FileDescriptor: Sendable, Hashable { func write(_ buffer: UnsafeRawBufferPointer) throws -> Int { guard let base = buffer.baseAddress, buffer.count > 0 else { return 0 } #if os(Windows) + if WindowsFileSystem.openOptions.get(self).contains(.append) { + // A pipe or console cannot seek, and appends anyway. + _ = try? seek(offset: 0, from: .end) + } return try Int(valueOrErrno { _write(rawValue, base, UInt32(min(buffer.count, Int(Int32.max)))) }) #elseif canImport(Darwin) || canImport(Glibc) || canImport(Musl) || canImport(Android) || os(WASI) return try valueOrErrno { _pal_write(rawValue, base, buffer.count) } @@ -172,10 +179,12 @@ struct FileDescriptor: Sendable, Hashable { #endif } - /// Reads at the given absolute offset without changing the current offset - /// (except on Windows, where the CRT offers no `pread` and the offset moves). + /// Reads at the given absolute offset without changing the current offset. func read(fromAbsoluteOffset offset: Int64, into buffer: UnsafeMutableRawBufferPointer) throws -> Int { #if os(Windows) + // The CRT offers no `pread`; seek there and back. + let current = try seek(offset: 0, from: .current) + defer { _ = try? seek(offset: current, from: .start) } _ = try seek(offset: offset, from: .start) return try read(into: buffer) #elseif canImport(Darwin) || canImport(Glibc) || canImport(Musl) || canImport(Android) || os(WASI) @@ -194,10 +203,16 @@ struct FileDescriptor: Sendable, Hashable { /// Writes all of `buffer` at the given absolute offset. func writeAll(toAbsoluteOffset offset: Int64, _ buffer: UnsafeRawBufferPointer) throws -> Int { #if os(Windows) + // The CRT offers no `pwrite`; seek there and back. Write with + // `_write` directly, since `write` would move to the end under + // append, and POSIX writes at the offset regardless. + let current = try seek(offset: 0, from: .current) + defer { _ = try? seek(offset: current, from: .start) } _ = try seek(offset: offset, from: .start) var written = 0 while written < buffer.count { - written += try write(UnsafeRawBufferPointer(rebasing: buffer[written...])) + let chunk = UnsafeRawBufferPointer(rebasing: buffer[written...]) + written += try Int(valueOrErrno { _write(rawValue, chunk.baseAddress!, UInt32(min(chunk.count, Int(Int32.max)))) }) } return written #elseif canImport(Darwin) || canImport(Glibc) || canImport(Musl) || canImport(Android) || os(WASI) @@ -274,8 +289,8 @@ struct FileDescriptor: Sendable, Hashable { /// bits representable as `OpenOptions` are reported. func status() throws -> OpenOptions { #if os(Windows) - // The CRT offers no F_GETFL equivalent; report no flags. - return [] + // The CRT offers no F_GETFL equivalent; report what open recorded. + return WindowsFileSystem.openOptions.get(self) #elseif os(WASI) throw WASIAbi.Errno.ENOTSUP #elseif canImport(Darwin) || canImport(Glibc) || canImport(Musl) || canImport(Android) @@ -288,7 +303,11 @@ struct FileDescriptor: Sendable, Hashable { func setStatus(_ options: OpenOptions) throws { #if os(Windows) - // The CRT offers no F_SETFL equivalent; accept and ignore. + // The CRT offers no F_SETFL equivalent. Like F_SETFL on Linux, + // change only append and nonblocking. + let settable: OpenOptions = [.append, .nonBlocking] + let current = WindowsFileSystem.openOptions.get(self) + WindowsFileSystem.openOptions.set(current.subtracting(settable).union(options.intersection(settable)), for: self) #elseif os(WASI) throw WASIAbi.Errno.ENOTSUP #elseif canImport(Darwin) || canImport(Glibc) || canImport(Musl) || canImport(Android) @@ -301,12 +320,7 @@ struct FileDescriptor: Sendable, Hashable { /// Queries file metadata; the C equivalent is `fstat`. func attributes() throws -> Attributes { #if os(Windows) - var info = BY_HANDLE_FILE_INFORMATION() - let handle = HANDLE(bitPattern: _get_osfhandle(rawValue)) - guard GetFileInformationByHandle(handle, &info) else { - throw _wasiError(fromWin32: GetLastError()) - } - return Attributes(windowsFileInformation: info) + return try WindowsFileSystem.attributes(of: self) #elseif canImport(Darwin) || canImport(Glibc) || canImport(Musl) || canImport(Android) || os(WASI) var statBuffer = stat() try valueOrErrno(retryOnInterrupt: false) { fstat(rawValue, &statBuffer) } @@ -370,6 +384,8 @@ struct FileDescriptor: Sendable, Hashable { path.withCString { openat(rawValue, $0, flags, _palMode(permissions)) } } return FileDescriptor(rawValue: fd) + #elseif os(Windows) + return try WindowsFileSystem.open(at: path, in: self, mode: mode, options: options) #else throw WASIAbi.Errno.ENOTSUP #endif @@ -384,6 +400,53 @@ struct FileDescriptor: Sendable, Hashable { path.withCString { fstatat(rawValue, $0, &statBuffer, options.contains(.noFollow) ? AT_SYMLINK_NOFOLLOW : 0) } } return Attributes(stat: statBuffer) + #elseif os(Windows) + // The Windows layer never follows a symlink; the sandbox does. + guard options.contains(.noFollow) else { throw WASIAbi.Errno.ENOTSUP } + return try WindowsFileSystem.attributes(at: path, in: self) + #else + throw WASIAbi.Errno.ENOTSUP + #endif + } + + /// Sets the timestamps of a path relative to this directory descriptor; + /// the C equivalent is `utimensat`. + func setTimes( + at path: String, options: AtOptions = [], + access: FileTime = .omit, modification: FileTime = .omit + ) throws { + #if canImport(Darwin) || canImport(Glibc) || canImport(Musl) || canImport(Android) || os(WASI) + let times = ContiguousArray([_posixTimespec(access), _posixTimespec(modification)]) + _ = try times.withUnsafeBufferPointer { timesPtr in + try valueOrErrno(retryOnInterrupt: false) { + path.withCString { + utimensat(rawValue, $0, timesPtr.baseAddress!, options.contains(.noFollow) ? AT_SYMLINK_NOFOLLOW : 0) + } + } + } + #elseif os(Windows) + // The Windows layer never follows a symlink; the sandbox does. + guard options.contains(.noFollow) else { throw WASIAbi.Errno.ENOTSUP } + try WindowsFileSystem.setTimes(at: path, in: self, access: access, modification: modification) + #else + throw WASIAbi.Errno.ENOTSUP + #endif + } + + /// Creates a hard link at `newPath` relative to `newDir` to the entry + /// at `path` relative to this descriptor, without following a symlink + /// there; the C equivalent is `linkat`. + func createHardLink(at path: String, to newDir: FileDescriptor, at newPath: String) throws { + #if canImport(Darwin) || canImport(Glibc) || canImport(Musl) || canImport(Android) || os(WASI) + try valueOrErrno(retryOnInterrupt: false) { + path.withCString { oldCStr in + newPath.withCString { newCStr in + linkat(rawValue, oldCStr, newDir.rawValue, newCStr, 0) + } + } + } + #elseif os(Windows) + try WindowsFileSystem.createHardLink(at: path, in: self, to: newPath, in: newDir) #else throw WASIAbi.Errno.ENOTSUP #endif @@ -396,6 +459,8 @@ struct FileDescriptor: Sendable, Hashable { try valueOrErrno(retryOnInterrupt: false) { path.withCString { unlinkat(rawValue, $0, options.contains(.removeDirectory) ? AT_REMOVEDIR : 0) } } + #elseif os(Windows) + try WindowsFileSystem.remove(at: path, in: self, directory: options.contains(.removeDirectory)) #else throw WASIAbi.Errno.ENOTSUP #endif @@ -408,14 +473,18 @@ struct FileDescriptor: Sendable, Hashable { try valueOrErrno(retryOnInterrupt: false) { path.withCString { mkdirat(rawValue, $0, _palMode(permissions)) } } + #elseif os(Windows) + try WindowsFileSystem.createDirectory(at: path, in: self) #else throw WASIAbi.Errno.ENOTSUP #endif } /// Creates a symlink at `link` (relative to this directory descriptor) - /// pointing to `original`; the C equivalent is `symlinkat`. - func createSymlink(original: String, link: String) throws { + /// pointing to `original`; the C equivalent is `symlinkat`. Windows + /// records whether a symlink points to a directory and asks + /// `targetIsDirectory`; other platforms never call it. + func createSymlink(original: String, link: String, targetIsDirectory: () -> Bool) throws { #if canImport(Darwin) || canImport(Glibc) || canImport(Musl) || canImport(Android) || os(WASI) try valueOrErrno(retryOnInterrupt: false) { original.withCString { originalCStr in @@ -424,6 +493,8 @@ struct FileDescriptor: Sendable, Hashable { } } } + #elseif os(Windows) + try WindowsFileSystem.createSymlink(original: original, link: link, in: self, targetIsDirectory: targetIsDirectory) #else throw WASIAbi.Errno.ENOTSUP #endif @@ -440,6 +511,8 @@ struct FileDescriptor: Sendable, Hashable { } } } + #elseif os(Windows) + try WindowsFileSystem.rename(at: path, in: self, to: newPath, in: newDir) #else throw WASIAbi.Errno.ENOTSUP #endif @@ -460,6 +533,12 @@ struct FileDescriptor: Sendable, Hashable { readlinkat(rawValue, $0, base.assumingMemoryBound(to: CChar.self), buffer.count) } } + #elseif os(Windows) + // Like readlink, truncate silently and add no NUL. + let target = Array(try WindowsFileSystem.readSymlink(at: path, in: self).utf8) + let count = min(target.count, buffer.count) + buffer.copyBytes(from: target[.. #elseif canImport(Glibc) || canImport(Musl) || canImport(Android) || os(WASI) fileprivate let dirp: OpaquePointer + #elseif os(Windows) + fileprivate let enumerator: WindowsFileSystem.DirectoryEnumerator #endif /// Closes the stream and the file descriptor it took ownership of. func close() { #if canImport(Darwin) || canImport(Glibc) || canImport(Musl) || canImport(Android) || os(WASI) _ = closedir(dirp) + #elseif os(Windows) + enumerator.close() #endif } @@ -492,6 +575,8 @@ struct FileDescriptor: Sendable, Hashable { let errnoValue = _palErrno if errnoValue == 0 { return nil } return .failure(_wasiError(fromErrno: errnoValue)) + #elseif os(Windows) + return enumerator.next() #else return nil #endif @@ -507,6 +592,8 @@ struct FileDescriptor: Sendable, Hashable { throw _wasiError(fromErrno: _palErrno) } return DirectoryStream(dirp: dirp) + #elseif os(Windows) + return DirectoryStream(enumerator: WindowsFileSystem.DirectoryEnumerator(fd: self)) #else throw WASIAbi.Errno.ENOTSUP #endif @@ -572,10 +659,12 @@ enum PlatformScheduler { #if !os(WASI) if flags & O_APPEND != 0 { options.insert(.append) } if flags & O_NONBLOCK != 0 { options.insert(.nonBlocking) } - if flags & O_DSYNC != 0 { options.insert(.dataSync) } - if flags & O_SYNC != 0 { options.insert(.fileSync) } + // Glibc's O_SYNC and O_RSYNC include the O_DSYNC bit, so test + // every bit of each rather than any of them. + if flags & O_DSYNC == O_DSYNC { options.insert(.dataSync) } + if flags & O_SYNC == O_SYNC { options.insert(.fileSync) } #if os(Linux) - if flags & O_RSYNC != 0 { options.insert(.readSync) } + if flags & O_RSYNC == O_RSYNC { options.insert(.readSync) } #endif #endif return options @@ -697,7 +786,7 @@ enum PlatformScheduler { default: fileType = .unknown } #endif - self.init(name: name, fileType: fileType) + self.init(name: name, fileType: fileType, inode: UInt64(entry.pointee.d_ino)) } } @@ -783,9 +872,11 @@ enum PlatformScheduler { } extension FileDescriptor.Attributes { - init(windowsFileInformation info: BY_HANDLE_FILE_INFORMATION) { + /// `isSymlink` comes from the reparse tag, which the file + /// information lacks: not every reparse point is a symlink. + init(windowsFileInformation info: BY_HANDLE_FILE_INFORMATION, isSymlink: Bool) { let fileType: FileDescriptor.FileType - if info.dwFileAttributes & DWORD(FILE_ATTRIBUTE_REPARSE_POINT) != 0 { + if isSymlink { fileType = .symlink } else if info.dwFileAttributes & DWORD(FILE_ATTRIBUTE_DIRECTORY) != 0 { fileType = .directory diff --git a/Sources/WASI/Platform/PAL/PALPoll.swift b/Sources/WASI/Platform/PAL/PALPoll.swift index 632223a2c..7550d6519 100644 --- a/Sources/WASI/Platform/PAL/PALPoll.swift +++ b/Sources/WASI/Platform/PAL/PALPoll.swift @@ -1,6 +1,7 @@ -// poll(2) wrapper for the WASI platform layer. Unavailable on Windows and on -// unknown platforms, where `poll_oneoff` reports `ENOTSUP` for fd -// subscriptions. +// poll(2) wrapper for the WASI platform layer. Windows has no poll for +// arbitrary handles, so readiness is probed per kind of handle there. +// Unavailable on unknown platforms, where `poll_oneoff` reports `ENOTSUP` for +// fd subscriptions. #if canImport(Darwin) import Darwin #elseif canImport(Glibc) @@ -11,6 +12,9 @@ import Android #elseif os(WASI) import WASILibc +#elseif os(Windows) + import ucrt + import WinSDK #endif enum PlatformPoll { @@ -54,18 +58,64 @@ enum PlatformPoll { guard result > 0 else { throw _wasiError(fromErrno: err) } - return pollfds.map { fd in + return zip(pollfds, subscriptions).map { fd, subscription in var state: ReadyState = [] + #if canImport(Darwin) + // Darwin's poll supports few devices besides ttys and flags + // the rest, /dev/null among them, POLLNVAL although they are + // open. select(2) reports such a device as always ready, so + // do the same; a closed descriptor still fails F_GETFD. + if fd.revents & Int16(POLLNVAL) != 0, fcntl(fd.fd, F_GETFD) != -1 { + state.insert(subscription.waitWrite ? .writable : .readable) + return state + } + #endif if fd.revents & Int16(POLLIN) != 0 { state.insert(.readable) } if fd.revents & Int16(POLLOUT) != 0 { state.insert(.writable) } if fd.revents & Int16(POLLHUP) != 0 { state.insert(.hangup) } if fd.revents & Int16(POLLERR | POLLNVAL) != 0 { state.insert(.error) } return state } + #elseif os(Windows) + let start = GetTickCount64() + while true { + let states = try subscriptions.map(windowsReadyState) + if states.contains(where: { !$0.isEmpty }) { return states } + if let timeoutMilliseconds, GetTickCount64() - start >= UInt64(timeoutMilliseconds) { return nil } + Sleep(1) + } #else throw WASIAbi.Errno.ENOTSUP #endif } + + #if os(Windows) + /// Probes one handle without blocking. Disk files and devices such as + /// NUL are always ready, as poll(2) reports regular files; a pipe is + /// readable once it holds data or its writer is gone; console input is + /// readable once it has pending events. + private static func windowsReadyState(_ subscription: Subscription) throws -> ReadyState { + guard let handle = HANDLE(bitPattern: _get_osfhandle(subscription.fd)), handle != INVALID_HANDLE_VALUE else { + return .error + } + let ready: ReadyState = subscription.waitWrite ? .writable : .readable + guard !subscription.waitWrite else { return ready } + switch Int32(GetFileType(handle)) { + case FILE_TYPE_PIPE: + var available: DWORD = 0 + guard PeekNamedPipe(handle, nil, 0, nil, &available, nil) else { + return GetLastError() == DWORD(ERROR_BROKEN_PIPE) ? .hangup : .error + } + return available > 0 ? ready : [] + case FILE_TYPE_CHAR: + var mode: DWORD = 0 + guard GetConsoleMode(handle, &mode) else { return ready } + return WaitForSingleObject(handle, 0) == WAIT_OBJECT_0 ? ready : [] + default: + return ready + } + } + #endif } #if canImport(Darwin) || canImport(Glibc) || canImport(Musl) || canImport(Android) || os(WASI) diff --git a/Sources/WASI/Platform/PAL/PALTypes.swift b/Sources/WASI/Platform/PAL/PALTypes.swift index e4960465f..92687e771 100644 --- a/Sources/WASI/Platform/PAL/PALTypes.swift +++ b/Sources/WASI/Platform/PAL/PALTypes.swift @@ -149,5 +149,6 @@ extension FileDescriptor { struct DirectoryEntry { let name: String let fileType: FileType + let inode: UInt64 } } diff --git a/Sources/WASI/Platform/PAL/PALWindows.swift b/Sources/WASI/Platform/PAL/PALWindows.swift new file mode 100644 index 000000000..4790e3765 --- /dev/null +++ b/Sources/WASI/Platform/PAL/PALWindows.swift @@ -0,0 +1,713 @@ +// Windows implementations of the directory-relative PAL primitives. +// +// Windows has no `openat` family in Win32, but the NT layer underneath has +// one: `NtCreateFile` opens a name relative to a directory HANDLE given as +// the `RootDirectory` of its object attributes, and `NtSetInformationFile` +// renames or links an open file into a directory given the same way. Every +// operation here goes through a directory HANDLE like that, never through a +// path rebuilt from one, so a guest renaming directories or planting +// symlinks concurrently cannot redirect it, just as with `openat` on POSIX. +// A directory is opened with backup semantics, and its HANDLE is wrapped in +// a CRT file descriptor so that `FileDescriptor` stays a CRT descriptor +// everywhere. +// +// The sandbox walker (`PathResolution`) hands these functions a single +// name, never a path, and resolves `..` and symlinks itself. They therefore +// never follow a symlink: every open uses `FILE_OPEN_REPARSE_POINT`, and an +// open that lands on a symlink fails with ELOOP, as `O_NOFOLLOW` does. +// +// Every file is opened with all three share modes, including +// `FILE_SHARE_DELETE`, so that an open file or directory can be renamed or +// unlinked as on POSIX, and deletion and renaming use the POSIX-semantics +// flags so that a name disappears at once even while open. +#if os(Windows) + import ucrt + import WinSDK + + enum WindowsFileSystem { + static let shareAll = DWORD(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) + // FILE_GENERIC_READ and FILE_GENERIC_WRITE are macros too complex to + // import; these are their definitions from winnt.h. + static let genericRead = DWORD(STANDARD_RIGHTS_READ) | DWORD(FILE_READ_DATA) | DWORD(FILE_READ_ATTRIBUTES) | DWORD(FILE_READ_EA) | DWORD(SYNCHRONIZE) + static let genericWrite = + DWORD(STANDARD_RIGHTS_WRITE) | DWORD(FILE_WRITE_DATA) | DWORD(FILE_WRITE_ATTRIBUTES) | DWORD(FILE_WRITE_EA) | DWORD(FILE_APPEND_DATA) + | DWORD(SYNCHRONIZE) + + static func handle(_ fd: FileDescriptor) -> HANDLE? { + HANDLE(bitPattern: _get_osfhandle(fd.rawValue)) + } + + static func lastError() -> any Error { + _wasiError(fromWin32: GetLastError()) + } + + // MARK: NT entry points + + // ntdll exports these, but no import library comes with the SDK the + // toolchain uses, so they are looked up at run time. + private typealias NtCreateFileFunction = + @convention(c) ( + UnsafeMutablePointer?, ACCESS_MASK, UnsafeMutablePointer?, + UnsafeMutablePointer?, UnsafeMutablePointer?, + ULONG, ULONG, ULONG, ULONG, UnsafeMutableRawPointer?, ULONG + ) -> NTSTATUS + private typealias NtSetInformationFileFunction = + @convention(c) ( + HANDLE?, UnsafeMutablePointer?, UnsafeMutableRawPointer?, ULONG, Int32 + ) -> NTSTATUS + private typealias RtlNtStatusToDosErrorFunction = @convention(c) (NTSTATUS) -> ULONG + + private struct NTFunctions: @unchecked Sendable { + let createFile: NtCreateFileFunction + let setInformationFile: NtSetInformationFileFunction + let statusToDosError: RtlNtStatusToDosErrorFunction + + init() { + let ntdll = GetModuleHandleW(Array("ntdll.dll".utf16) + [0]) + func lookup(_ name: String, as type: F.Type) -> F { + guard let address = GetProcAddress(ntdll, name) else { + fatalError("ntdll.dll does not export \(name)") + } + return unsafeBitCast(address, to: type) + } + createFile = lookup("NtCreateFile", as: NtCreateFileFunction.self) + setInformationFile = lookup("NtSetInformationFile", as: NtSetInformationFileFunction.self) + statusToDosError = lookup("RtlNtStatusToDosError", as: RtlNtStatusToDosErrorFunction.self) + } + } + private static let nt = NTFunctions() + + // NT values the SDK headers leave out. + private static let fileCreated: ULONG_PTR = 2 + private static let fileRenameInformation: Int32 = 10 + private static let fileLinkInformation: Int32 = 11 + private static let fileRenameInformationEx: Int32 = 65 + private static let fileLinkInformationEx: Int32 = 72 + private static let fileLinkReplaceIfExists: DWORD = 0x1 + private static let fileLinkPOSIXSemantics: DWORD = 0x2 + + static func error(fromStatus status: NTSTATUS) -> any Error { + switch UInt32(bitPattern: status) { + case 0xC000_00BA: // STATUS_FILE_IS_A_DIRECTORY + return WASIAbi.Errno.EISDIR + case 0xC000_0103: // STATUS_NOT_A_DIRECTORY + return WASIAbi.Errno.ENOTDIR + case 0xC000_0056: // STATUS_DELETE_PENDING: the name is already gone. + return WASIAbi.Errno.ENOENT + default: + return _wasiError(fromWin32: UInt32(nt.statusToDosError(status))) + } + } + + /// `NtCreateFile` for `name` relative to the directory `dir`, never + /// following a reparse point at `name`. `.` names `dir` itself; `..` + /// is refused, since only the sandbox walker may leave a directory. + static func openRelative( + _ dir: HANDLE?, _ name: String, + access: DWORD, disposition: ULONG, options: ULONG = 0, + created: UnsafeMutablePointer? = nil + ) throws -> HANDLE { + var units: [UInt16] + switch name { + case "", ".": units = [] + case "..": throw WASIAbi.Errno.EPERM + default: + // A backslash would be a separator to Windows and a colon + // names an alternate data stream; neither is a single name. + guard !name.utf16.contains(UInt16(UInt8(ascii: "\\"))), !name.utf16.contains(UInt16(UInt8(ascii: ":"))) else { + throw WASIAbi.Errno.ENOENT + } + units = Array(name.utf16) + } + guard units.count * 2 <= Int(UInt16.max) else { throw WASIAbi.Errno.ENAMETOOLONG } + + var handle: HANDLE? = nil + var ioStatus = IO_STATUS_BLOCK() + let status = units.withUnsafeMutableBufferPointer { buffer -> NTSTATUS in + let byteCount = USHORT(buffer.count * 2) + var objectName = UNICODE_STRING(Length: byteCount, MaximumLength: byteCount, Buffer: buffer.baseAddress) + return withUnsafeMutablePointer(to: &objectName) { objectName in + var attributes = OBJECT_ATTRIBUTES( + Length: ULONG(MemoryLayout.size), RootDirectory: dir, + ObjectName: objectName, Attributes: ULONG(OBJ_CASE_INSENSITIVE), + SecurityDescriptor: nil, SecurityQualityOfService: nil) + return nt.createFile( + &handle, ACCESS_MASK(access) | ACCESS_MASK(SYNCHRONIZE), &attributes, &ioStatus, nil, + ULONG(FILE_ATTRIBUTE_NORMAL), ULONG(shareAll), disposition, + options | ULONG(FILE_SYNCHRONOUS_IO_NONALERT | FILE_OPEN_FOR_BACKUP_INTENT | FILE_OPEN_REPARSE_POINT), + nil, 0) + } + } + guard status >= 0, let handle else { throw error(fromStatus: status) } + created?.pointee = ioStatus.Information == fileCreated + return handle + } + + /// Opens `name` in `dir` for the duration of `body`. + static func withRelative( + _ dir: FileDescriptor, _ name: String, access: DWORD, options: ULONG = 0, + _ body: (HANDLE) throws -> R + ) throws -> R { + let handle = try openRelative(Self.handle(dir), name, access: access, disposition: ULONG(FILE_OPEN), options: options) + defer { CloseHandle(handle) } + return try body(handle) + } + + // MARK: Metadata + + static func information(of handle: HANDLE?) throws -> BY_HANDLE_FILE_INFORMATION { + var info = BY_HANDLE_FILE_INFORMATION() + guard GetFileInformationByHandle(handle, &info) else { throw lastError() } + return info + } + + /// The reparse tag of the entry behind `handle`, or nil when it is + /// not a reparse point. + static func reparseTag(of handle: HANDLE?) throws -> UInt32? { + var info = FILE_ATTRIBUTE_TAG_INFO() + guard GetFileInformationByHandleEx(handle, FileAttributeTagInfo, &info, DWORD(MemoryLayout.size(ofValue: info))) else { + throw lastError() + } + return info.FileAttributes & DWORD(FILE_ATTRIBUTE_REPARSE_POINT) != 0 ? info.ReparseTag : nil + } + + /// Whether a reparse tag redirects to another name, as symlinks and + /// junctions do. Other reparse points, such as cloud-file + /// placeholders, are ordinary files and directories to their users. + static func isNameSurrogate(_ tag: UInt32?) -> Bool { + guard let tag else { return false } + return tag & 0x2000_0000 != 0 + } + + static func isSameFile(_ a: BY_HANDLE_FILE_INFORMATION, _ b: BY_HANDLE_FILE_INFORMATION) -> Bool { + a.dwVolumeSerialNumber == b.dwVolumeSerialNumber && a.nFileIndexHigh == b.nFileIndexHigh && a.nFileIndexLow == b.nFileIndexLow + } + + static func isDirectory(_ info: BY_HANDLE_FILE_INFORMATION) -> Bool { + info.dwFileAttributes & DWORD(FILE_ATTRIBUTE_DIRECTORY) != 0 + } + + /// `fstat`. Character devices such as NUL and the console have no + /// file information, so only their type is reported. + static func attributes(of fd: FileDescriptor) throws -> FileDescriptor.Attributes { + let handle = handle(fd) + var info = BY_HANDLE_FILE_INFORMATION() + if GetFileInformationByHandle(handle, &info) { + let isSymlink = isNameSurrogate(try? reparseTag(of: handle)) + return FileDescriptor.Attributes(windowsFileInformation: info, isSymlink: isSymlink) + } + let error = GetLastError() + guard GetFileType(handle) == DWORD(FILE_TYPE_CHAR) else { throw _wasiError(fromWin32: error) } + let zero = FileTime(seconds: 0, nanoseconds: 0) + return FileDescriptor.Attributes( + device: 0, inode: 0, fileType: .characterDevice, linkCount: 1, size: 0, + accessTime: zero, modificationTime: zero, creationTime: zero) + } + + /// `fstatat` with `AT_SYMLINK_NOFOLLOW`. + static func attributes(at name: String, in dir: FileDescriptor) throws -> FileDescriptor.Attributes { + try withRelative(dir, name, access: DWORD(FILE_READ_ATTRIBUTES)) { handle in + FileDescriptor.Attributes( + windowsFileInformation: try information(of: handle), + isSymlink: isNameSurrogate(try reparseTag(of: handle))) + } + } + + static func setTimes(of handle: HANDLE?, access: FileTime, modification: FileTime) throws { + var atime = _windowsFILETIME(from: access) + var mtime = _windowsFILETIME(from: modification) + guard atime != nil || mtime != nil else { return } + let ok = withOptionalPointer(to: &atime) { atimePtr in + withOptionalPointer(to: &mtime) { mtimePtr in + SetFileTime(handle, nil, atimePtr, mtimePtr) + } + } + guard ok else { throw lastError() } + } + + /// `utimensat` with `AT_SYMLINK_NOFOLLOW`. + static func setTimes(at name: String, in dir: FileDescriptor, access: FileTime, modification: FileTime) throws { + try withRelative(dir, name, access: DWORD(FILE_WRITE_ATTRIBUTES)) { handle in + try setTimes(of: handle, access: access, modification: modification) + } + } + + // MARK: Opening + + /// Wraps a HANDLE in a CRT descriptor, closing the HANDLE on failure. + static func wrap(_ handle: HANDLE, crtFlags: CInt) throws -> FileDescriptor { + let fd = _open_osfhandle(intptr_t(bitPattern: handle), crtFlags) + guard fd >= 0 else { + CloseHandle(handle) + throw _wasiError(fromErrno: _palErrno) + } + return FileDescriptor(rawValue: fd) + } + + /// Opens a host directory, given by the embedder as a host path. + static func openPreopenDirectory(_ path: String) throws -> FileDescriptor { + let widePath = Array(path.utf16) + [0] + let handle = CreateFileW(widePath, genericRead, shareAll, nil, DWORD(OPEN_EXISTING), DWORD(FILE_FLAG_BACKUP_SEMANTICS), nil) + guard let handle, handle != INVALID_HANDLE_VALUE else { throw lastError() } + do { + guard isDirectory(try information(of: handle)) else { throw WASIAbi.Errno.ENOTDIR } + } catch { + CloseHandle(handle) + throw error + } + return try wrap(handle, crtFlags: _O_RDONLY) + } + + /// `openat` with `O_NOFOLLOW`, with POSIX outcomes: a symlink fails + /// with ELOOP, `.directory` on anything else with ENOTDIR, and + /// writing, creating or truncating a directory with EISDIR. + static func open( + at name: String, in dir: FileDescriptor, + mode: FileDescriptor.AccessMode, options: FileDescriptor.OpenOptions + ) throws -> FileDescriptor { + if options.contains(.directory) && options.contains(.create) { + // `open("name/", O_CREAT)`: POSIX refuses to create a directory this way. + throw WASIAbi.Errno.EISDIR + } + var access: DWORD + switch mode { + case .readOnly: access = genericRead + case .writeOnly: access = genericWrite + case .readWrite: access = genericRead | genericWrite + } + // POSIX lets any descriptor set timestamps and read metadata. + access |= DWORD(FILE_READ_ATTRIBUTES | FILE_WRITE_ATTRIBUTES) + + // Never let the open itself overwrite: the entry might be a + // symlink or a directory, which O_TRUNC must leave alone. A + // regular file is truncated below, once its type is known. + let disposition: Int32 + switch (options.contains(.create), options.contains(.exclusiveCreate)) { + case (true, true): disposition = FILE_CREATE + case (true, false): disposition = FILE_OPEN_IF + case (false, _): disposition = FILE_OPEN + } + var ntOptions: ULONG = 0 + if options.contains(.directory) { ntOptions |= ULONG(FILE_DIRECTORY_FILE) } + if options.contains(.fileSync) || options.contains(.dataSync) { ntOptions |= ULONG(FILE_WRITE_THROUGH) } + + var created = false + var handle: HANDLE + do { + handle = try openRelative(Self.handle(dir), name, access: access, disposition: ULONG(disposition), options: ntOptions, created: &created) + } catch WASIAbi.Errno.EACCES { + // Without write permission on the file, FILE_WRITE_ATTRIBUTES + // alone can be what is denied; retry without it. + access &= ~DWORD(FILE_WRITE_ATTRIBUTES) + handle = try openRelative(Self.handle(dir), name, access: access, disposition: ULONG(disposition), options: ntOptions, created: &created) + } + + do { + let info = try information(of: handle) + if isNameSurrogate(try reparseTag(of: handle)) { + throw WASIAbi.Errno.ELOOP + } + if options.contains(.directory) && !isDirectory(info) { + throw WASIAbi.Errno.ENOTDIR + } + if isDirectory(info) && (mode != .readOnly || options.contains(.create) || options.contains(.truncate)) { + throw WASIAbi.Errno.EISDIR + } + if options.contains(.truncate) && !created { + var endOfFile = FILE_END_OF_FILE_INFO() + guard SetFileInformationByHandle(handle, FileEndOfFileInfo, &endOfFile, DWORD(MemoryLayout.size(ofValue: endOfFile))) else { + throw lastError() + } + } + } catch { + CloseHandle(handle) + throw error + } + + let fd = try wrap(handle, crtFlags: mode == .readOnly ? _O_RDONLY : 0) + openOptions.set(options.intersection(OpenOptionsTable.recorded), for: fd) + return fd + } + + /// The status flags POSIX keeps with an open file (`F_GETFL`) but + /// Windows does not, by CRT descriptor. `write` seeks to the end + /// first when `.append` is set, as the CRT's own `_O_APPEND` does. + static let openOptions = OpenOptionsTable() + + final class OpenOptionsTable: @unchecked Sendable { + static let recorded: FileDescriptor.OpenOptions = [.append, .nonBlocking, .dataSync, .fileSync, .readSync] + private let lock = UnsafeMutablePointer.allocate(capacity: 1) + private var options: [CInt: FileDescriptor.OpenOptions] = [:] + + init() { + InitializeSRWLock(lock) + } + + func get(_ fd: FileDescriptor) -> FileDescriptor.OpenOptions { + AcquireSRWLockShared(lock) + defer { ReleaseSRWLockShared(lock) } + return options[fd.rawValue] ?? [] + } + + func set(_ value: FileDescriptor.OpenOptions, for fd: FileDescriptor) { + AcquireSRWLockExclusive(lock) + defer { ReleaseSRWLockExclusive(lock) } + options[fd.rawValue] = value.isEmpty ? nil : value + } + } + + static func createDirectory(at name: String, in dir: FileDescriptor) throws { + let handle = try openRelative( + Self.handle(dir), name, access: DWORD(FILE_LIST_DIRECTORY), disposition: ULONG(FILE_CREATE), + options: ULONG(FILE_DIRECTORY_FILE)) + CloseHandle(handle) + } + + // MARK: Symlinks + + private static let reparseTagSymlink: UInt32 = 0xA000_000C + private static let reparseTagMountPoint: UInt32 = 0xA000_0003 + private static let symlinkFlagRelative: UInt32 = 1 + + /// `readlinkat`: the link's target with `/` separators. An absolute + /// target gets a leading `/` so that the sandbox walker refuses it. + static func readSymlink(at name: String, in dir: FileDescriptor) throws -> String { + try withRelative(dir, name, access: DWORD(FILE_READ_ATTRIBUTES)) { handle in + var buffer = [UInt8](repeating: 0, count: Int(MAXIMUM_REPARSE_DATA_BUFFER_SIZE)) + var returned: DWORD = 0 + let ok = DeviceIoControl(handle, DWORD(FSCTL_GET_REPARSE_POINT), nil, 0, &buffer, DWORD(buffer.count), &returned, nil) + guard ok else { + if GetLastError() == DWORD(ERROR_NOT_A_REPARSE_POINT) { throw WASIAbi.Errno.EINVAL } + throw lastError() + } + // REPARSE_DATA_BUFFER is only in the driver kit headers, so + // read it by hand: ReparseTag (u32), ReparseDataLength (u16), + // Reserved (u16), then SubstituteNameOffset/Length and + // PrintNameOffset/Length (u16 each); a symlink adds Flags + // (u32) before the UTF-16 path buffer, a junction does not. + return try buffer.withUnsafeBytes { bytes -> String in + let tag = bytes.loadUnaligned(fromByteOffset: 0, as: UInt32.self) + let pathBufferOffset: Int + let isRelative: Bool + switch tag { + case reparseTagSymlink: + pathBufferOffset = 20 + isRelative = bytes.loadUnaligned(fromByteOffset: 16, as: UInt32.self) & symlinkFlagRelative != 0 + case reparseTagMountPoint: + pathBufferOffset = 16 + isRelative = false + default: + throw WASIAbi.Errno.EINVAL + } + let offset = Int(bytes.loadUnaligned(fromByteOffset: 8, as: UInt16.self)) + let length = Int(bytes.loadUnaligned(fromByteOffset: 10, as: UInt16.self)) + let start = pathBufferOffset + offset + guard start + length <= Int(returned) else { throw WASIAbi.Errno.EINVAL } + let units = (0.. Bool) throws { + // A drive letter, a stream or a backslash-separated path would + // all mean something else to Windows than to the guest. + guard !original.utf16.contains(UInt16(UInt8(ascii: ":"))), !original.utf16.contains(UInt16(UInt8(ascii: "\\"))) else { + throw WASIAbi.Errno.EPERM + } + let target = Array(original.replacingCharacter("/", with: "\\").utf16) + let isDirectory = targetIsDirectory() + + let entry: HANDLE + do { + entry = try openRelative( + handle(dir), link, access: genericWrite | DWORD(DELETE), disposition: ULONG(FILE_CREATE), + options: ULONG(isDirectory ? FILE_DIRECTORY_FILE : FILE_NON_DIRECTORY_FILE)) + } catch WASIAbi.Errno.EISDIR, WASIAbi.Errno.ENOTDIR { + // Something of the other type is already there. + throw WASIAbi.Errno.EEXIST + } + defer { CloseHandle(entry) } + + // A symlink REPARSE_DATA_BUFFER (see `readSymlink`) holding the + // target as both substitute name and print name. + let nameBytes = target.count * 2 + guard 12 + nameBytes * 2 <= Int(UInt16.max) else { + try? delete(entry) + throw WASIAbi.Errno.ENAMETOOLONG + } + var buffer = [UInt8](repeating: 0, count: 20 + nameBytes * 2) + buffer.withUnsafeMutableBytes { bytes in + bytes.storeBytes(of: reparseTagSymlink, toByteOffset: 0, as: UInt32.self) + bytes.storeBytes(of: UInt16(12 + nameBytes * 2), toByteOffset: 4, as: UInt16.self) + bytes.storeBytes(of: 0, toByteOffset: 8, as: UInt16.self) + bytes.storeBytes(of: UInt16(nameBytes), toByteOffset: 10, as: UInt16.self) + bytes.storeBytes(of: UInt16(nameBytes), toByteOffset: 12, as: UInt16.self) + bytes.storeBytes(of: UInt16(nameBytes), toByteOffset: 14, as: UInt16.self) + bytes.storeBytes(of: symlinkFlagRelative, toByteOffset: 16, as: UInt32.self) + for (index, unit) in (target + target).enumerated() { + bytes.storeBytes(of: unit, toByteOffset: 20 + index * 2, as: UInt16.self) + } + } + var returned: DWORD = 0 + if DeviceIoControl(entry, DWORD(FSCTL_SET_REPARSE_POINT), &buffer, DWORD(buffer.count), nil, 0, &returned, nil) { + return + } + let error = GetLastError() + try? delete(entry) + guard error == DWORD(ERROR_PRIVILEGE_NOT_HELD) else { throw _wasiError(fromWin32: error) } + + var flags = DWORD(SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE) + if isDirectory { flags |= DWORD(SYMBOLIC_LINK_FLAG_DIRECTORY) } + let linkPath = try finalPath(of: handle(dir)) + Array("\\\(link)".utf16) + [0] + guard CreateSymbolicLinkW(linkPath, target + [0], flags) != 0 else { throw lastError() } + } + + /// The final path of an open directory, in `\\?\` form, without a + /// NUL terminator. Only the unprivileged symlink fallback uses it. + private static func finalPath(of handle: HANDLE?) throws -> [UInt16] { + var buffer = [UInt16](repeating: 0, count: Int(MAX_PATH)) + while true { + let count = Int( + GetFinalPathNameByHandleW(handle, &buffer, DWORD(buffer.count), DWORD(FILE_NAME_NORMALIZED | VOLUME_NAME_DOS))) + guard count > 0 else { throw lastError() } + // On success the count excludes the NUL; when the buffer is + // too small it is the size needed, NUL included. + if count < buffer.count { return Array(buffer[...size)) else { + throw lastError() + } + } + + /// `unlinkat`, with or without `AT_REMOVEDIR`. A symlink is removed + /// as a file even when Windows considers it a directory symlink. + static func remove(at name: String, in dir: FileDescriptor, directory: Bool) throws { + guard name != "." && name != ".." else { + throw directory ? WASIAbi.Errno.EINVAL : WASIAbi.Errno.EISDIR + } + try withRelative(dir, name, access: DWORD(DELETE) | DWORD(FILE_READ_ATTRIBUTES)) { handle in + let info = try information(of: handle) + let isRealDirectory = try isDirectory(info) && !isNameSurrogate(reparseTag(of: handle)) + if directory && !isRealDirectory { throw WASIAbi.Errno.ENOTDIR } + if !directory && isRealDirectory { throw WASIAbi.Errno.EISDIR } + try delete(handle) + } + } + + /// Gives the entry behind `handle` the name `name` in `dir`, by + /// `NtSetInformationFile` with a FILE_RENAME_INFORMATION or + /// FILE_LINK_INFORMATION. The two share FILE_RENAME_INFO's layout. + private static func setName( + of handle: HANDLE, in dir: FileDescriptor, to name: String, + extendedClass: Int32, legacyClass: Int32, replace: Bool + ) throws { + let units = Array(name.utf16) + let nameBytes = units.count * MemoryLayout.size + let nameOffset = MemoryLayout.offset(of: \FILE_RENAME_INFO.FileName)! + let size = max(MemoryLayout.size, nameOffset + nameBytes + MemoryLayout.size) + let buffer = UnsafeMutableRawPointer.allocate(byteCount: size, alignment: MemoryLayout.alignment) + defer { buffer.deallocate() } + + func attempt(extended: Bool) -> NTSTATUS { + buffer.initializeMemory(as: UInt8.self, repeating: 0, count: size) + let info = buffer.assumingMemoryBound(to: FILE_RENAME_INFO.self) + if extended { + info.pointee.Flags = fileLinkPOSIXSemantics | (replace ? fileLinkReplaceIfExists : 0) + } else { + info.pointee.ReplaceIfExists = replace ? 1 : 0 + } + info.pointee.RootDirectory = Self.handle(dir) + info.pointee.FileNameLength = DWORD(nameBytes) + units.withUnsafeBytes { name in + if let base = name.baseAddress { (buffer + nameOffset).copyMemory(from: base, byteCount: nameBytes) } + } + var ioStatus = IO_STATUS_BLOCK() + return nt.setInformationFile(handle, &ioStatus, buffer, ULONG(size), extended ? extendedClass : legacyClass) + } + + var status = attempt(extended: true) + // File systems other than NTFS may not support the extended form. + if UInt32(bitPattern: status) == 0xC000_0003 || UInt32(bitPattern: status) == 0xC000_00BB { + status = attempt(extended: false) // STATUS_INVALID_INFO_CLASS, STATUS_NOT_SUPPORTED + } + guard status >= 0 else { throw error(fromStatus: status) } + } + + /// `renameat`. A trailing `/` on either name requires a directory. + static func rename(at oldName: String, in oldDir: FileDescriptor, to newName: String, in newDir: FileDescriptor) throws { + let oldRequiresDirectory = oldName.hasSuffix("/") + let newRequiresDirectory = newName.hasSuffix("/") + let oldName = String(oldName.reversed().drop(while: { $0 == "/" }).reversed()) + let newName = String(newName.reversed().drop(while: { $0 == "/" }).reversed()) + // Neither the directory itself nor its parent can be renamed + // through it; in particular a preopen cannot be moved. + for name in [oldName, newName] where name.isEmpty || name == "." || name == ".." { + throw WASIAbi.Errno.EBUSY + } + + try withRelative(oldDir, oldName, access: DWORD(DELETE) | DWORD(FILE_READ_ATTRIBUTES)) { handle in + let oldInfo = try information(of: handle) + let oldIsDirectory = try isDirectory(oldInfo) && !isNameSurrogate(reparseTag(of: handle)) + if oldRequiresDirectory && !oldIsDirectory { throw WASIAbi.Errno.ENOTDIR } + + let new: (info: BY_HANDLE_FILE_INFORMATION, isDirectory: Bool)? + do { + new = try withRelative(newDir, newName, access: DWORD(FILE_READ_ATTRIBUTES)) { + let info = try information(of: $0) + return (info, try isDirectory(info) && !isNameSurrogate(reparseTag(of: $0))) + } + } catch WASIAbi.Errno.ENOENT { + new = nil + } + if let new { + // Two links to one file: POSIX does nothing. The same + // name in another case in the same directory is that very + // entry, though, and renaming it changes the case. + let sameFile = isSameFile(new.info, oldInfo) + let isCaseChange = + try oldName != newName && oldName.lowercased() == newName.lowercased() + && isSameFile(information(of: Self.handle(oldDir)), information(of: Self.handle(newDir))) + if sameFile && !isCaseChange { + return + } + if !sameFile { + if new.isDirectory && !oldIsDirectory { throw WASIAbi.Errno.EISDIR } + if !new.isDirectory && (oldIsDirectory || newRequiresDirectory) { throw WASIAbi.Errno.ENOTDIR } + } + } else if newRequiresDirectory && !oldIsDirectory { + throw WASIAbi.Errno.ENOTDIR + } + // With POSIX semantics, NTFS also replaces an empty directory. + try setName( + of: handle, in: newDir, to: newName, + extendedClass: fileRenameInformationEx, legacyClass: fileRenameInformation, replace: true) + } + } + + /// `linkat` without following a symlink at the source. + static func createHardLink(at oldName: String, in oldDir: FileDescriptor, to newName: String, in newDir: FileDescriptor) throws { + try withRelative(oldDir, oldName, access: DWORD(FILE_READ_ATTRIBUTES)) { handle in + let info = try information(of: handle) + if try isDirectory(info) && !isNameSurrogate(reparseTag(of: handle)) { + throw WASIAbi.Errno.EPERM + } + try setName( + of: handle, in: newDir, to: newName, + extendedClass: fileLinkInformationEx, legacyClass: fileLinkInformation, replace: false) + } + } + + // MARK: Directory enumeration + + /// Reads a directory through its HANDLE in batches of + /// `FILE_ID_BOTH_DIR_INFO` records. Like `readdir`, it yields `.` and + /// `..`, and each record carries the file ID, so no entry needs to be + /// opened for its inode. + final class DirectoryEnumerator { + let fd: FileDescriptor + private var buffer = [UInt8](repeating: 0, count: 64 * 1024) + private var offset: Int? + private var restart = true + private var finished = false + + init(fd: FileDescriptor) { + self.fd = fd + } + + func next() -> Result? { + if offset == nil { + guard !finished else { return nil } + let ok = GetFileInformationByHandleEx( + WindowsFileSystem.handle(fd), restart ? FileIdBothDirectoryRestartInfo : FileIdBothDirectoryInfo, + &buffer, DWORD(buffer.count)) + restart = false + guard ok else { + finished = true + let error = GetLastError() + return error == DWORD(ERROR_NO_MORE_FILES) ? nil : .failure(_wasiError(fromWin32: error)) + } + offset = 0 + } + return buffer.withUnsafeBytes { bytes in + let entryOffset = offset! + let entry = (bytes.baseAddress! + entryOffset).assumingMemoryBound(to: FILE_ID_BOTH_DIR_INFO.self) + let nameOffset = MemoryLayout.offset(of: \FILE_ID_BOTH_DIR_INFO.FileName)! + let nameLength = Int(entry.pointee.FileNameLength) / 2 + let units = (0..(to value: inout T?, _ body: (UnsafePointer?) -> R) -> R { + if value != nil { + return withUnsafePointer(to: &value!) { body($0) } + } + return body(nil) + } + } + + extension String { + fileprivate func replacingCharacter(_ target: Character, with replacement: Character) -> String { + String(map { $0 == target ? replacement : $0 }) + } + } +#endif diff --git a/Sources/WASI/Platform/SandboxPrimitives/Open.swift b/Sources/WASI/Platform/SandboxPrimitives/Open.swift index c30b36e09..04ea677e8 100644 --- a/Sources/WASI/Platform/SandboxPrimitives/Open.swift +++ b/Sources/WASI/Platform/SandboxPrimitives/Open.swift @@ -14,7 +14,7 @@ struct PathResolution { private var components: [GuestPath.Component] private var resolvedSymlinks: Int = 0 - private static var MAX_SYMLINKS: Int { + static var MAX_SYMLINKS: Int { // Linux defines MAXSYMLINKS as 40, but on darwin platforms, it's 32. // Take a single conservative value here to avoid platform-specific // behavior as much as possible. diff --git a/Sources/WASI/Platform/SandboxPrimitives/OpenParent.swift b/Sources/WASI/Platform/SandboxPrimitives/OpenParent.swift index 471084a14..ecf6cafd3 100644 --- a/Sources/WASI/Platform/SandboxPrimitives/OpenParent.swift +++ b/Sources/WASI/Platform/SandboxPrimitives/OpenParent.swift @@ -40,6 +40,32 @@ extension SandboxPrimitives { } return splitPath } + + /// Like `openParent`, but while the last component names a symlink, + /// replaces it with the symlink's target and resolves again, so that the + /// result never names a symlink. The host never follows a symlink + /// itself, which could lead it out of the sandbox. + static func openParentFollowingSymlinks(start: FileDescriptor, path: String) throws -> SplitPath { + var path = path + for _ in 0...PathResolution.MAX_SYMLINKS { + let result = try openParent(start: start, path: path) + let target: [UInt8]? = try result.withFields { dir, basename in + guard try dir.attributes(at: basename, options: .noFollow).fileType.isSymlink else { return nil } + return try readSymlink(in: dir, name: basename) + } + guard let target else { return result } + let targetPath = String(decoding: target, as: UTF8.self) + guard !GuestPath(targetPath).isAbsolute else { + // Ban absolute symlink to avoid sandbox-escaping. + throw WASIAbi.Errno.EPERM + } + // The target is relative to the directory holding the symlink. + // `start` stays the base, so `..` cannot climb above it. + guard let (parent, _) = splitParent(path: path) else { throw WASIAbi.Errno.ENOENT } + path = parent.isEmpty ? targetPath : parent.string + "/" + targetPath + } + throw WASIAbi.Errno.ELOOP + } } /// The return value of `SandboxPrimitives.openParent`. diff --git a/Sources/WASI/Platform/SandboxPrimitives/Readlink.swift b/Sources/WASI/Platform/SandboxPrimitives/Readlink.swift index d0d4a960e..309e40463 100644 --- a/Sources/WASI/Platform/SandboxPrimitives/Readlink.swift +++ b/Sources/WASI/Platform/SandboxPrimitives/Readlink.swift @@ -1,22 +1,27 @@ extension SandboxPrimitives { static func readlinkAt(start: FileDescriptor, path: String) throws -> [UInt8] { - let initialBufferCapacity = 256 - let maxBufferCapacity = max(initialBufferCapacity, FileDescriptor.maximumPathLength) - let result = try openParent(start: start, path: path) return try result.withFields { dir, basename in - var capacity = min(initialBufferCapacity, maxBufferCapacity) - while true { - var buffer = [UInt8](repeating: 0, count: capacity) - let count = try buffer.withUnsafeMutableBytes { rawBuffer in - try dir.readSymlink(at: basename, into: rawBuffer) - } + try readSymlink(in: dir, name: basename) + } + } + + /// Reads the target of the symlink `name` in `dir`, growing the buffer + /// until the whole target fits. + static func readSymlink(in dir: FileDescriptor, name: String) throws -> [UInt8] { + let initialBufferCapacity = 256 + let maxBufferCapacity = max(initialBufferCapacity, FileDescriptor.maximumPathLength) + var capacity = min(initialBufferCapacity, maxBufferCapacity) + while true { + var buffer = [UInt8](repeating: 0, count: capacity) + let count = try buffer.withUnsafeMutableBytes { rawBuffer in + try dir.readSymlink(at: name, into: rawBuffer) + } - if count < capacity || capacity == maxBufferCapacity { - return Array(buffer.prefix(count)) - } - capacity = min(capacity * 2, maxBufferCapacity) + if count < capacity || capacity == maxBufferCapacity { + return Array(buffer.prefix(count)) } + capacity = min(capacity * 2, maxBufferCapacity) } } } diff --git a/Sources/WASI/WASI.swift b/Sources/WASI/WASI.swift index 745e5a467..e9eb7c5a9 100644 --- a/Sources/WASI/WASI.swift +++ b/Sources/WASI/WASI.swift @@ -972,7 +972,8 @@ final class WASIImplementation: Sendable { /// Force the allocation of space in a file. func fd_allocate(fd: WASIAbi.Fd, offset: WASIAbi.FileSize, length: WASIAbi.FileSize) throws { try fdTable.withLock { table in - guard table[fd] != nil else { + // Only regular files can be allocated. + guard case .file = table[fd] else { throw WASIAbi.Errno.EBADF } } @@ -1337,7 +1338,14 @@ final class WASIImplementation: Sendable { oldFd: WASIAbi.Fd, oldFlags: WASIAbi.LookupFlags, oldPath: String, newFd: WASIAbi.Fd, newPath: String ) throws { - throw WASIAbi.Errno.ENOTSUP + // Hard-linking the target of a symlink would let a link inside the + // sandbox name a file outside it, so following is refused as in wasmtime. + guard !oldFlags.contains(.SYMLINK_FOLLOW) else { + throw WASIAbi.Errno.EINVAL + } + let oldDirEntry = try directoryEntry(fd: oldFd) + let newDirEntry = try directoryEntry(fd: newFd) + try oldDirEntry.link(from: oldPath, toDir: newDirEntry, to: newPath) } /// Open a file or directory. @@ -1412,6 +1420,11 @@ final class WASIImplementation: Sendable { /// Create a symbolic link. func path_symlink(oldPath: String, dirFd: WASIAbi.Fd, newPath: String) throws { + // Sandboxed resolution never follows an absolute target, so refuse + // to create such a link at all, as wasmtime does. + guard !oldPath.hasPrefix("/") else { + throw WASIAbi.Errno.EPERM + } let dirEntry = try directoryEntry(fd: dirFd) try dirEntry.symlink(from: oldPath, to: newPath) } diff --git a/Tests/WASITests/IntegrationTests.swift b/Tests/WASITests/IntegrationTests.swift index 3b8a392fe..fa250ed41 100644 --- a/Tests/WASITests/IntegrationTests.swift +++ b/Tests/WASITests/IntegrationTests.swift @@ -3,11 +3,24 @@ import Testing import WasmKit import WasmKitWASI +#if os(Windows) + import ucrt +#elseif canImport(Android) + import Android +#elseif canImport(Glibc) + import Glibc +#elseif canImport(Darwin) + import Darwin +#endif + @Suite struct IntegrationTests { #if !os(Android) + // One at a time, as under the upstream runner: several cases create + // the same names, such as `file.cleanup`, in the shared root. @Test( + .serialized, arguments: try IntegrationTests.discoverAllTests() ) func run(test: URL) throws { @@ -21,111 +34,24 @@ struct IntegrationTests { let path: URL let reason: String } - struct SuiteManifest: Codable { - let name: String - } - static var skipTests: [String: Set] { - #if os(Windows) - return [ - "WASI Assemblyscript tests": [], - "WASI C tests": [ - "fdopendir-with-access", - "fopen-with-access", - "lseek", - "pread-with-access", - "pwrite-with-access", - "pwrite-with-append", - "sock_shutdown-invalid_fd", - "sock_shutdown-not_sock", - "stat-dev-ino", - ], - "WASI Rust tests": [ - "close_preopen", - "dangling_fd", - "dangling_symlink", - "dir_fd_op_failures", - "directory_seek", - "fd_advise", - "fd_fdstat_set_rights", - "fd_filestat_set", - "fd_flags_set", - "fd_readdir", - "file_allocate", - "file_pread_pwrite", - "file_seek_tell", - "file_truncation", - "file_unbuffered_write", - "fstflags_validate", - "interesting_paths", - "isatty", - "nofollow_errors", - "overwrite_preopen", - "path_exists", - "path_filestat", - "path_link", - "path_open_create_existing", - "path_open_dirfd_not_dir", - "path_open_missing", - "path_open_nonblock", - "path_open_preopen", - "path_open_read_write", - "path_rename", - "path_rename_dir_trailing_slashes", - "path_symlink_trailing_slashes", - "poll_oneoff_stdio", - "readlink", - "remove_directory_trailing_slashes", - "remove_nonempty_directory", - "renumber", - "sched_yield", - "stdio", - "symlink_create", - "symlink_filestat", - "symlink_loop", - "truncation_rights", - "unlink_file_trailing_slashes", - ], - ] - #else - var tests: [String: Set] = [ - "WASI Rust tests": [ - "overwrite_preopen", - "path_link", - "dir_fd_op_failures", - "path_filestat", - "symlink_filestat", - "path_open_read_write", - "path_open_preopen", - "fd_fdstat_set_rights", - "file_allocate", - "symlink_create", - ], - "WASI C tests": [], - ] - #if os(Linux) - tests["WASI C tests"]?.insert("pwrite-with-append") - #endif - return tests - #endif - } + /// The WASI version whose prebuilt tests we run. The suites also ship + /// `wasm32-wasip3` components, which need the component model. + static let wasiVersion = "wasm32-wasip1" static func discoverAllTests() throws -> [URL] { let testDir = URL(fileURLWithPath: #filePath) .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() .appendingPathComponent("Vendor/wasi-testsuite") var tests = [URL]() - for testSuitePath in ["tests/assemblyscript/testsuite", "tests/c/testsuite", "tests/rust/testsuite"] { - let suitePath = testDir.appendingPathComponent(testSuitePath) + for language in ["assemblyscript", "c", "rust"] { + let suitePath = testDir.appendingPathComponent("tests/\(language)/testsuite/\(wasiVersion)") tests.append(contentsOf: try discoverTestsFromSuite(path: suitePath)) } return tests } static func discoverTestsFromSuite(path: URL) throws -> [URL] { - let manifestPath = path.appendingPathComponent("manifest.json") - let manifest = try JSONDecoder().decode(SuiteManifest.self, from: Data(contentsOf: manifestPath)) - // Clean up **/*.cleanup do { let enumerator = FileManager.default.enumerator(at: path, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])! @@ -137,29 +63,29 @@ struct IntegrationTests { } let tests = try FileManager.default.contentsOfDirectory(at: path, includingPropertiesForKeys: nil, options: []) - - let skipTests = Self.skipTests[manifest.name] ?? [] - - var testCases = [URL]() - for test in tests { - guard test.pathExtension == "wasm" else { continue } - let testName = test.deletingPathExtension().lastPathComponent - if skipTests.contains(testName) { - continue - } - testCases.append(test) - } - return testCases + return tests.filter { $0.pathExtension == "wasm" }.sorted { $0.path < $1.path } } + /// A test specification, as described in `doc/specification.md` of + /// wasi-testsuite. Only the legacy form is read: no wasip1 test uses + /// the operation-based form, whose extra steps (sockets, HTTP) need a + /// subprocess to talk to. struct CaseManifest: Codable { - var dirs: [String]? var args: [String]? var env: [String: String]? + /// A directory, relative to the test, preopened as the guest's `/`. + var root: String? var exitCode: UInt32? + var stdout: String? + var stderr: String? + var operations: [Operation]? + + struct Operation: Codable { + var type: String + } static var empty: CaseManifest { - CaseManifest(dirs: nil, args: nil, env: nil, exitCode: nil) + CaseManifest() } } @@ -173,35 +99,106 @@ struct IntegrationTests { } else { manifest = .empty } - - // HACK: WasmKit intentionally does not support fd_allocate - if path.lastPathComponent == "fd_advise.wasm" { - manifest.env = (manifest.env ?? [:]).merging(["NO_FD_ALLOCATE": "1"]) { _, new in new } + guard manifest.operations == nil else { + Issue.record("\(path.lastPathComponent): operation-based test specifications are not supported") + return } let suitePath = path.deletingLastPathComponent() - - let preopens = (manifest.dirs ?? []).map { - WASIBridgeToHost.Preopen( - guestPath: $0, - hostPath: suitePath.appendingPathComponent($0).path - ) - } + let preopens = + manifest.root.map { + [WASIBridgeToHost.Preopen(guestPath: "/", hostPath: suitePath.appendingPathComponent($0).path)] + } ?? [] + + // Give the guest files rather than our own terminal, so its output + // can be checked and, like under the upstream runner, no stdio is a tty. + let outputDir = FileManager.default.temporaryDirectory + .appendingPathComponent("wasi-testsuite-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: outputDir) } + let stdoutPath = outputDir.appendingPathComponent("stdout") + let stderrPath = outputDir.appendingPathComponent("stderr") + #if os(Windows) + let nullDevice = "NUL" + #else + let nullDevice = "/dev/null" + #endif + let stdin = try Self.openStdioFile(nullDevice, write: false) + defer { Self.closeStdioFile(stdin) } + let stdout = try Self.openStdioFile(stdoutPath.path, write: true) + defer { Self.closeStdioFile(stdout) } + let stderr = try Self.openStdioFile(stderrPath.path, write: true) + defer { Self.closeStdioFile(stderr) } let wasi = try WASIBridgeToHost( args: [path.path] + (manifest.args ?? []), environment: manifest.env ?? [:], - fileSystem: .host().withStdio().withPreopens(preopens) + fileSystem: .host() + .withStdio( + stdin: stdin, + stdout: stdout, + stderr: stderr + ) + .withPreopens(preopens) ) - try wasi.runAndClose { wasi in - let engine = Engine() - let store = Store(engine: engine) - var imports = Imports() - wasi.link(to: &imports, store: store) - let module = try parseWasm(filePath: path.path) - let instance = try module.instantiate(store: store, imports: imports) - let exitCode = try wasi.start(instance) - #expect(exitCode == manifest.exitCode ?? 0, "\(path.path)") + let result = Result { + try wasi.runAndClose { wasi in + let engine = Engine() + let store = Store(engine: engine) + var imports = Imports() + wasi.link(to: &imports, store: store) + let module = try parseWasm(filePath: path.path) + let instance = try module.instantiate(store: store, imports: imports) + return try wasi.start(instance) + } + } + // Decode leniently, so that output in another encoding is reported + // as a mismatch rather than thrown. + let actualStdout = String(decoding: try Data(contentsOf: stdoutPath), as: UTF8.self) + let actualStderr = String(decoding: try Data(contentsOf: stderrPath), as: UTF8.self) + let output = "stdout: \(actualStdout)\nstderr: \(actualStderr)" + let exitCode: UInt32 + switch result { + case .success(let code): exitCode = code + case .failure(let error): + // A trap usually follows a panic message on stderr. + Issue.record(error, "\(path.path)\n\(output)") + return + } + #expect(exitCode == manifest.exitCode ?? 0, "\(path.path)\n\(output)") + // The upstream runner reads as many bytes as it expects and compares those. + if let expected = manifest.stdout { + #expect(actualStdout.hasPrefix(expected), "\(path.path)\n\(output)") + } + if let expected = manifest.stderr { + #expect(actualStderr.hasPrefix(expected), "\(path.path)\n\(output)") + } + } + + /// Opens a file as a platform descriptor for the guest's stdio. + /// `FileHandle.fileDescriptor` is unavailable on Windows, so this goes + /// through the C runtime directly. + static func openStdioFile(_ path: String, write: Bool) throws -> CInt { + #if os(Windows) + var fd: CInt = -1 + let flags = (write ? _O_WRONLY | _O_CREAT | _O_TRUNC : _O_RDONLY) | _O_BINARY + _ = path.withCString(encodedAs: UTF16.self) { widePath in + _wsopen_s(&fd, widePath, flags, _SH_DENYNO, _S_IREAD | _S_IWRITE) + } + #else + let fd = open(path, write ? O_WRONLY | O_CREAT | O_TRUNC : O_RDONLY, 0o644) + #endif + guard fd >= 0 else { + throw CocoaError(.fileNoSuchFile, userInfo: [NSFilePathErrorKey: path]) } + return fd + } + + static func closeStdioFile(_ fd: CInt) { + #if os(Windows) + _ = _close(fd) + #else + _ = close(fd) + #endif } } diff --git a/Tests/WASITests/WASICapabilityIntegrationTests.swift b/Tests/WASITests/WASICapabilityIntegrationTests.swift index de5e6019b..5ba789496 100644 --- a/Tests/WASITests/WASICapabilityIntegrationTests.swift +++ b/Tests/WASITests/WASICapabilityIntegrationTests.swift @@ -18,7 +18,7 @@ import WasmKitWASI static let environGuest = URL(fileURLWithPath: #filePath) .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() .appendingPathComponent( - "Vendor/wasi-testsuite/tests/assemblyscript/testsuite/environ_get-multiple-variables.wasm") + "Vendor/wasi-testsuite/tests/assemblyscript/testsuite/wasm32-wasip1/environ_get-multiple-variables.wasm") private static let environment = ["a": "text", "b": "escap \" ing", "c": "new\nline"] diff --git a/Tests/WASITests/WASITests.swift b/Tests/WASITests/WASITests.swift index 0fe59a85f..729fee1d8 100644 --- a/Tests/WASITests/WASITests.swift +++ b/Tests/WASITests/WASITests.swift @@ -20,34 +20,6 @@ import WasmTypes #endif return try FileManager.default.contentsOfDirectory(atPath: fdDirectory).count } - - private func withReducedOpenFileLimit( - extraDescriptors: Int, - _ body: () throws -> Result - ) throws -> Result { - #if os(macOS) - let nofileResource = RLIMIT_NOFILE - #else - let nofileResource = __rlimit_resource_t(RLIMIT_NOFILE.rawValue) - #endif - var original = rlimit() - guard getrlimit(nofileResource, &original) == 0 else { - throw TestSupport.Error(errno: errno) - } - - let currentOpen = try currentOpenFileDescriptorCount() - let desiredSoft = rlim_t(currentOpen + extraDescriptors) - var reduced = original - reduced.rlim_cur = min(original.rlim_cur, desiredSoft) - guard setrlimit(nofileResource, &reduced) == 0 else { - throw TestSupport.Error(errno: errno) - } - defer { - var original = original - _ = setrlimit(nofileResource, &original) - } - return try body() - } #endif @Suite @@ -187,6 +159,116 @@ struct WASITests { } #endif + #if !os(Android) + /// `path_filestat_get` and `path_filestat_set_times` follow a final + /// symlink inside the sandbox, never through the host, so a link + /// pointing out of the preopen reveals nothing about its target. + @Test + func pathFilestatFollowsSymlinksInsideTheSandbox() throws { + let t = try TestSupport.TemporaryDirectory() + try t.createDir(at: "External") + try t.createFile(at: "External/secret.txt", contents: "Secret") + try t.createDir(at: "Sandbox") + try t.createDir(at: "Sandbox/dir") + try t.createFile(at: "Sandbox/hello.txt", contents: "Hello") + try t.createSymlink(at: "Sandbox/link-hello.txt", to: "hello.txt") + try t.createSymlink(at: "Sandbox/dir/link-up-hello.txt", to: "../link-hello.txt") + try t.createSymlink(at: "Sandbox/link-external-secret.txt", to: "../External/secret.txt") + try t.createSymlink(at: "Sandbox/link-root", to: "/") + try t.createSymlink(at: "Sandbox/link-loop.txt", to: "link-loop.txt") + + let wasi = try WASIBridgeToHost( + fileSystem: .host().withPreopens([ + .init(guestPath: "/Sandbox", hostPath: t.url.appendingPathComponent("Sandbox").path) + ]) + ) + try wasi.runAndClose { _ in + let wasi = wasi.underlying + let sandboxFd: WASIAbi.Fd = 3 + + let hello = try wasi.path_filestat_get(dirFd: sandboxFd, flags: .SYMLINK_FOLLOW, path: "link-hello.txt") + #expect(hello.size == 5) + let upHello = try wasi.path_filestat_get(dirFd: sandboxFd, flags: .SYMLINK_FOLLOW, path: "dir/link-up-hello.txt") + #expect(upHello.size == 5) + let link = try wasi.path_filestat_get(dirFd: sandboxFd, flags: [], path: "link-hello.txt") + #expect(link.filetype == .SYMBOLIC_LINK) + #expect(throws: WASIAbi.Errno.EPERM) { + try wasi.path_filestat_get(dirFd: sandboxFd, flags: .SYMLINK_FOLLOW, path: "link-external-secret.txt") + } + #expect(throws: WASIAbi.Errno.EPERM) { + try wasi.path_filestat_get(dirFd: sandboxFd, flags: .SYMLINK_FOLLOW, path: "link-root") + } + #expect(throws: WASIAbi.Errno.ELOOP) { + try wasi.path_filestat_get(dirFd: sandboxFd, flags: .SYMLINK_FOLLOW, path: "link-loop.txt") + } + #expect(throws: WASIAbi.Errno.EPERM) { + try wasi.path_filestat_set_times( + dirFd: sandboxFd, flags: .SYMLINK_FOLLOW, path: "link-external-secret.txt", + atim: 0, mtim: 0, fstFlags: [.ATIM, .MTIM]) + } + } + } + + /// A hard link to a symlink links the symlink itself, so linking one + /// that points out of the preopen does not reach its target. + @Test + func pathLinkDoesNotFollowSymlinks() throws { + let t = try TestSupport.TemporaryDirectory() + try t.createDir(at: "External") + try t.createFile(at: "External/secret.txt", contents: "Secret") + try t.createDir(at: "Sandbox") + try t.createSymlink(at: "Sandbox/link-external-secret.txt", to: "../External/secret.txt") + + let wasi = try WASIBridgeToHost( + fileSystem: .host().withPreopens([ + .init(guestPath: "/Sandbox", hostPath: t.url.appendingPathComponent("Sandbox").path) + ]) + ) + try wasi.runAndClose { _ in + let wasi = wasi.underlying + let sandboxFd: WASIAbi.Fd = 3 + + try wasi.path_link( + oldFd: sandboxFd, oldFlags: [], oldPath: "link-external-secret.txt", + newFd: sandboxFd, newPath: "hard-link") + let link = try wasi.path_filestat_get(dirFd: sandboxFd, flags: [], path: "hard-link") + #expect(link.filetype == .SYMBOLIC_LINK) + #expect(throws: WASIAbi.Errno.EPERM) { + try wasi.path_filestat_get(dirFd: sandboxFd, flags: .SYMLINK_FOLLOW, path: "hard-link") + } + #expect(throws: WASIAbi.Errno.EINVAL) { + try wasi.path_link( + oldFd: sandboxFd, oldFlags: .SYMLINK_FOLLOW, oldPath: "link-external-secret.txt", + newFd: sandboxFd, newPath: "followed-link") + } + } + } + + /// A preopen cannot be renamed, or renamed over, through itself. + @Test + func renamingThePreopenItselfIsRefused() throws { + let t = try TestSupport.TemporaryDirectory() + try t.createDir(at: "Sandbox") + try t.createDir(at: "Sandbox/dir") + + let wasi = try WASIBridgeToHost( + fileSystem: .host().withPreopens([ + .init(guestPath: "/Sandbox", hostPath: t.url.appendingPathComponent("Sandbox").path) + ]) + ) + try wasi.runAndClose { _ in + let wasi = wasi.underlying + let sandboxFd: WASIAbi.Fd = 3 + for (oldPath, newPath) in [(".", "moved"), ("dir/..", "moved"), ("dir", ".")] { + #expect(throws: (any Error).self, "\(oldPath) -> \(newPath)") { + try wasi.path_rename(oldFd: sandboxFd, oldPath: oldPath, newFd: sandboxFd, newPath: newPath) + } + } + } + #expect(FileManager.default.fileExists(atPath: t.url.appendingPathComponent("Sandbox/dir").path)) + } + #endif + @Test func memoryFileSystem() throws { let fs = try MemoryFileSystem() @@ -1687,17 +1769,23 @@ struct WASITests { count: 4096 ) - try withReducedOpenFileLimit(extraDescriptors: 32) { - for _ in 0..<512 { - let bytesRead = try wasi.fd_readdir( - fd: dirFd, - buffer: buffer, - cookie: 0, - memory: memory - ) - #expect(bytesRead > 0) - } + // Count descriptors rather than lowering RLIMIT_NOFILE: the + // limit is process-wide, so it would starve tests running in + // parallel. A leak shows as one descriptor per call, far more + // than those tests hold open at once. + let iterations = 512 + let openBefore = try currentOpenFileDescriptorCount() + for _ in 0.. 0) } + let openAfter = try currentOpenFileDescriptorCount() + #expect(openAfter - openBefore < iterations / 2) } } #endif diff --git a/Vendor/dependencies.json b/Vendor/dependencies.json index 7890acb9e..7b34008b5 100644 --- a/Vendor/dependencies.json +++ b/Vendor/dependencies.json @@ -6,7 +6,7 @@ }, "wasi-testsuite": { "repository": "https://github.com/WebAssembly/wasi-testsuite.git", - "revision": "f442fae1a214263aeeddb5395c0c50dc9403b7f8", + "revision": "e0aa527fab67f2f311882bcee4f62cc755433b73", "categories": ["default"] }, "wasi-threads": {