From 7f89aafba3b10cd3bb2f69042ff261a006567fc3 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Sun, 4 Oct 2026 15:56:17 +0000 Subject: [PATCH 01/15] Check for a readdir descriptor leak without lowering the fd limit fdReaddirDoesNotLeakFileDescriptors lowered RLIMIT_NOFILE to 32 above the descriptors open at the time. The limit is process-wide, so any test opening files in parallel could fail with EMFILE while the loop ran, and fileDescriptorLeakTest and the wasi-testsuite cases sometimes did. Count the open descriptors before and after the loop instead; a leak adds one per call, far more than parallel tests hold at once. --- Tests/WASITests/WASITests.swift | 54 ++++++++++----------------------- 1 file changed, 16 insertions(+), 38 deletions(-) diff --git a/Tests/WASITests/WASITests.swift b/Tests/WASITests/WASITests.swift index 0fe59a85f..f6004091e 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 @@ -1687,17 +1659,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 From bd9a5ba1a859be720f510a34b5ce38e059d8c510 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Sun, 4 Oct 2026 16:04:37 +0000 Subject: [PATCH 02/15] Report devices Darwin cannot poll as ready Darwin's poll supports few devices besides ttys, and flags the others, /dev/null among them, POLLNVAL even though they are open. poll_oneoff passed that on as BADF, so a guest polling stdin failed when it was redirected from /dev/null, as poll_oneoff_stdio does under a test runner. select(2) reports such a device as always ready, so report it ready too when the descriptor is still open. --- Sources/WASI/Platform/PAL/PALPoll.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Sources/WASI/Platform/PAL/PALPoll.swift b/Sources/WASI/Platform/PAL/PALPoll.swift index 632223a2c..73f851c75 100644 --- a/Sources/WASI/Platform/PAL/PALPoll.swift +++ b/Sources/WASI/Platform/PAL/PALPoll.swift @@ -54,8 +54,18 @@ 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) } From 36b2b30f7a531525bd862c57fcf1acf92ee32ff3 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Mon, 5 Oct 2026 14:13:30 +0000 Subject: [PATCH 03/15] Follow the final symlink of path_filestat_get inside the sandbox With SYMLINK_FOLLOW, path_filestat_get resolved all but the last component in the sandbox and then let the host's fstatat follow a symlink there. A symlink pointing out of the preopen, such as `../../etc/passwd` or `/`, therefore reported the size, times and inode of a host file outside it. Resolve the final symlink in the sandbox too: while the last component names a symlink, read it and resolve its target relative to the same preopen, refusing absolute targets and stopping after as many links as path_open allows. The host then only ever stats something that is not a symlink. --- Sources/WASI/Platform/Directory.swift | 13 ++--- .../Platform/SandboxPrimitives/Open.swift | 2 +- .../SandboxPrimitives/OpenParent.swift | 26 ++++++++++ .../Platform/SandboxPrimitives/Readlink.swift | 31 ++++++----- Tests/WASITests/WASITests.swift | 51 +++++++++++++++++++ 5 files changed, 103 insertions(+), 20 deletions(-) diff --git a/Sources/WASI/Platform/Directory.swift b/Sources/WASI/Platform/Directory.swift index 378cf31c4..faaee08f0 100644 --- a/Sources/WASI/Platform/Directory.swift +++ b/Sources/WASI/Platform/Directory.swift @@ -219,13 +219,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/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/Tests/WASITests/WASITests.swift b/Tests/WASITests/WASITests.swift index f6004091e..a3c0eea28 100644 --- a/Tests/WASITests/WASITests.swift +++ b/Tests/WASITests/WASITests.swift @@ -159,6 +159,57 @@ struct WASITests { } #endif + #if !os(Windows) + /// `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]) + } + } + } + #endif + @Test func memoryFileSystem() throws { let fs = try MemoryFileSystem() From 2557e49dfdf27f34bd7effb1a019d978fd6bb24c Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Mon, 5 Oct 2026 14:14:30 +0000 Subject: [PATCH 04/15] Take directory entry inodes from readdir fd_readdir stat'ed every entry it returned for its inode, and stat'ed the entries it skipped to reach the cookie too, then threw those results away. Listing a directory of n entries in chunks therefore cost O(n^2) fstatat calls, since each call starts over from the first entry. Use the inode readdir already reports, as wasmtime does, and skip entries without building them. --- Sources/WASI/Platform/Directory.swift | 10 +++++----- Sources/WASI/Platform/PAL/PALFileDescriptor.swift | 2 +- Sources/WASI/Platform/PAL/PALTypes.swift | 1 + 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Sources/WASI/Platform/Directory.swift b/Sources/WASI/Platform/Directory.swift index faaee08f0..8dfeaca8d 100644 --- a/Sources/WASI/Platform/Directory.swift +++ b/Sources/WASI/Platform/Directory.swift @@ -153,7 +153,6 @@ extension DirEntry: WASIDir, FdWASIEntry { } struct HostDirectoryIterator: WASIReaddirIterator { - let fd: FileDescriptor let stream: FileDescriptor.DirectoryStream var entryIndex: Int @@ -166,7 +165,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 +172,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 +186,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) ) diff --git a/Sources/WASI/Platform/PAL/PALFileDescriptor.swift b/Sources/WASI/Platform/PAL/PALFileDescriptor.swift index 3af40adbd..849e8b952 100644 --- a/Sources/WASI/Platform/PAL/PALFileDescriptor.swift +++ b/Sources/WASI/Platform/PAL/PALFileDescriptor.swift @@ -697,7 +697,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)) } } 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 } } From e084feaa864722d1ffd44693f262631e9077dec6 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Sun, 4 Oct 2026 15:53:15 +0000 Subject: [PATCH 05/15] Run the latest wasi-testsuite The pinned revision was from July 2025. Upstream has since moved each suite's binaries under a per-WASI-version directory, preopens a test's `root` as the guest's `/` instead of listing `dirs`, and checks stdout and stderr. Point at the newest prebuilt binaries and follow the current specification: run the wasm32-wasip1 suites, preopen the root, and give the guest files for stdio so that its output can be checked and none of them is a tty, as under the upstream runner. A trap now reports what the guest printed to stderr, which is where a Rust test explains its panic. Cases run one at a time, as under the upstream runner, since several create the same names in the shared root. Of the eleven tests skipped on Linux and macOS, six pass against the new binaries. The other five stay skipped until the following commits fix them. fd_advise no longer needs NO_FD_ALLOCATE, since the test now accepts NOTSUP from fd_allocate. --- Tests/WASITests/IntegrationTests.swift | 178 +++++++++++++----- .../WASICapabilityIntegrationTests.swift | 2 +- Vendor/dependencies.json | 2 +- 3 files changed, 137 insertions(+), 45 deletions(-) diff --git a/Tests/WASITests/IntegrationTests.swift b/Tests/WASITests/IntegrationTests.swift index 3b8a392fe..3d6f3f01b 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 { @@ -88,35 +101,29 @@ struct IntegrationTests { ], ] #else - var tests: [String: Set] = [ + return [ "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": [], + "dir_fd_op_failures", // fd_allocate on a directory returns NOTSUP instead of BADF + "path_filestat", // path_open drops the SYNC fdflag + "path_link", // path_link is not implemented + "symlink_create", // path_symlink accepts an absolute target + "symlink_filestat", // path_filestat_set_times without SYMLINK_FOLLOW fails with LOOP on a symlink + ] ] - #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 @@ -138,7 +145,9 @@ struct IntegrationTests { let tests = try FileManager.default.contentsOfDirectory(at: path, includingPropertiesForKeys: nil, options: []) - let skipTests = Self.skipTests[manifest.name] ?? [] + // Suite names carry the WASI version, e.g. "WASI C tests [wasm32-wasip1]". + let suiteName = manifest.name.replacingOccurrences(of: " [\(wasiVersion)]", with: "") + let skipTests = Self.skipTests[suiteName] ?? [] var testCases = [URL]() for test in tests { @@ -149,17 +158,29 @@ struct IntegrationTests { } testCases.append(test) } - return testCases + return testCases.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 +194,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.root.map { + [WASIBridgeToHost.Preopen(guestPath: "/", hostPath: suitePath.appendingPathComponent($0).path)] + } ?? [] - let preopens = (manifest.dirs ?? []).map { - WASIBridgeToHost.Preopen( - guestPath: $0, - 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/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": { From 407bf5e532e1ee7ae4c9a2e813ea879bd4000882 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Sun, 4 Oct 2026 15:57:04 +0000 Subject: [PATCH 06/15] Reject fd_allocate on a directory with BADF fd_allocate answered NOTSUP for any open descriptor, including a directory, where dir_fd_op_failures expects ISDIR, BADF or NOTCAPABLE like every other file-only operation. Check that the descriptor is a file first, and answer BADF otherwise as wasmtime does. --- Sources/WASI/WASI.swift | 3 ++- Tests/WASITests/IntegrationTests.swift | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/WASI/WASI.swift b/Sources/WASI/WASI.swift index 745e5a467..858270d4b 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 } } diff --git a/Tests/WASITests/IntegrationTests.swift b/Tests/WASITests/IntegrationTests.swift index 3d6f3f01b..80ad3bb6d 100644 --- a/Tests/WASITests/IntegrationTests.swift +++ b/Tests/WASITests/IntegrationTests.swift @@ -103,7 +103,6 @@ struct IntegrationTests { #else return [ "WASI Rust tests": [ - "dir_fd_op_failures", // fd_allocate on a directory returns NOTSUP instead of BADF "path_filestat", // path_open drops the SYNC fdflag "path_link", // path_link is not implemented "symlink_create", // path_symlink accepts an absolute target From 1e66e57fef9f87dda8cb9551d743aadedb0bbd8e Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Sun, 4 Oct 2026 15:57:56 +0000 Subject: [PATCH 07/15] Open files with the sync and nonblocking fdflags they ask for path_open passed only APPEND of the requested fdflags on to the host, so a file opened with SYNC, DSYNC, RSYNC or NONBLOCK did not get them, and fd_fdstat_get did not report them back, which path_filestat checks. Pass them all on. Reading the flags back also tested O_SYNC and O_RSYNC for any of their bits. Glibc defines both to include O_DSYNC, so a DSYNC file was reported as SYNC too. Require every bit instead. --- Sources/WASI/Platform/Directory.swift | 4 +--- Sources/WASI/Platform/PAL/PALFileDescriptor.swift | 8 +++++--- Tests/WASITests/IntegrationTests.swift | 1 - 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Sources/WASI/Platform/Directory.swift b/Sources/WASI/Platform/Directory.swift index 8dfeaca8d..f9502d602 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)) { diff --git a/Sources/WASI/Platform/PAL/PALFileDescriptor.swift b/Sources/WASI/Platform/PAL/PALFileDescriptor.swift index 849e8b952..189ef3c8c 100644 --- a/Sources/WASI/Platform/PAL/PALFileDescriptor.swift +++ b/Sources/WASI/Platform/PAL/PALFileDescriptor.swift @@ -572,10 +572,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 diff --git a/Tests/WASITests/IntegrationTests.swift b/Tests/WASITests/IntegrationTests.swift index 80ad3bb6d..0ae0d18ea 100644 --- a/Tests/WASITests/IntegrationTests.swift +++ b/Tests/WASITests/IntegrationTests.swift @@ -103,7 +103,6 @@ struct IntegrationTests { #else return [ "WASI Rust tests": [ - "path_filestat", // path_open drops the SYNC fdflag "path_link", // path_link is not implemented "symlink_create", // path_symlink accepts an absolute target "symlink_filestat", // path_filestat_set_times without SYMLINK_FOLLOW fails with LOOP on a symlink From c029474dcd47854ab5930899579f78bf9fb116a4 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Sun, 4 Oct 2026 15:58:48 +0000 Subject: [PATCH 08/15] Set timestamps on a symlink itself without following it path_filestat_set_times opened its target to call futimens, with O_NOFOLLOW when the guest did not ask to follow symlinks. Opening a symlink that way fails with ELOOP, so the times of a symlink itself could not be set, which symlink_filestat does. Call utimensat with AT_SYMLINK_NOFOLLOW on the last component instead, as wasmtime does; openParent still resolves the rest of the path inside the sandbox. When following, resolve the final symlink inside the sandbox as path_filestat_get does, and set the times of what it names the same way. Opening the target is no longer needed at all: it opened for writing, which failed on every directory, and it blocked on a FIFO. --- Sources/WASI/Platform/Directory.swift | 20 +++++++++---------- .../WASI/Platform/PAL/PALFileDescriptor.swift | 20 +++++++++++++++++++ Tests/WASITests/IntegrationTests.swift | 1 - 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/Sources/WASI/Platform/Directory.swift b/Sources/WASI/Platform/Directory.swift index f9502d602..c59746401 100644 --- a/Sources/WASI/Platform/Directory.swift +++ b/Sources/WASI/Platform/Directory.swift @@ -77,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) } } diff --git a/Sources/WASI/Platform/PAL/PALFileDescriptor.swift b/Sources/WASI/Platform/PAL/PALFileDescriptor.swift index 189ef3c8c..c4474ee4d 100644 --- a/Sources/WASI/Platform/PAL/PALFileDescriptor.swift +++ b/Sources/WASI/Platform/PAL/PALFileDescriptor.swift @@ -389,6 +389,26 @@ struct FileDescriptor: Sendable, Hashable { #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) + } + } + } + #else + throw WASIAbi.Errno.ENOTSUP + #endif + } + /// Removes a file or directory entry relative to this directory /// descriptor; the C equivalent is `unlinkat`. func remove(at path: String, options: RemoveOptions = []) throws { diff --git a/Tests/WASITests/IntegrationTests.swift b/Tests/WASITests/IntegrationTests.swift index 0ae0d18ea..811b5bb23 100644 --- a/Tests/WASITests/IntegrationTests.swift +++ b/Tests/WASITests/IntegrationTests.swift @@ -105,7 +105,6 @@ struct IntegrationTests { "WASI Rust tests": [ "path_link", // path_link is not implemented "symlink_create", // path_symlink accepts an absolute target - "symlink_filestat", // path_filestat_set_times without SYMLINK_FOLLOW fails with LOOP on a symlink ] ] #endif From 604216dac7dc6d91c083ca92c86b5b6f3c301243 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Sun, 4 Oct 2026 15:59:32 +0000 Subject: [PATCH 09/15] Implement path_link path_link answered NOTSUP. Create the link with linkat on the parents that openParent resolves inside the sandbox, without AT_SYMLINK_FOLLOW, so that a symlink is linked rather than its target. A guest asking to follow is refused with INVAL, as in wasmtime, since the host would otherwise resolve the symlink outside the sandbox. WASIDir gains a link requirement. Its default answers NOTSUP, so the memory file system and SPI implementations outside this package keep building. --- Sources/WASI/FileSystem.swift | 9 +++++++++ Sources/WASI/Platform/Directory.swift | 13 +++++++++++++ .../WASI/Platform/PAL/PALFileDescriptor.swift | 17 +++++++++++++++++ Sources/WASI/WASI.swift | 9 ++++++++- Tests/WASITests/IntegrationTests.swift | 1 - 5 files changed, 47 insertions(+), 2 deletions(-) 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 c59746401..0a71f380a 100644 --- a/Sources/WASI/Platform/Directory.swift +++ b/Sources/WASI/Platform/Directory.swift @@ -115,6 +115,19 @@ extension DirEntry: WASIDir, FdWASIEntry { } } + 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) + } + } + } + func rename(from sourcePath: String, toDir newDir: any WASIDir, to destPath: String) throws { guard let newDir = newDir as? Self else { throw WASIAbi.Errno.EBADF diff --git a/Sources/WASI/Platform/PAL/PALFileDescriptor.swift b/Sources/WASI/Platform/PAL/PALFileDescriptor.swift index c4474ee4d..c3ba67be1 100644 --- a/Sources/WASI/Platform/PAL/PALFileDescriptor.swift +++ b/Sources/WASI/Platform/PAL/PALFileDescriptor.swift @@ -409,6 +409,23 @@ struct FileDescriptor: Sendable, Hashable { #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) + } + } + } + #else + throw WASIAbi.Errno.ENOTSUP + #endif + } + /// Removes a file or directory entry relative to this directory /// descriptor; the C equivalent is `unlinkat`. func remove(at path: String, options: RemoveOptions = []) throws { diff --git a/Sources/WASI/WASI.swift b/Sources/WASI/WASI.swift index 858270d4b..515addc63 100644 --- a/Sources/WASI/WASI.swift +++ b/Sources/WASI/WASI.swift @@ -1338,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. diff --git a/Tests/WASITests/IntegrationTests.swift b/Tests/WASITests/IntegrationTests.swift index 811b5bb23..39a27f65c 100644 --- a/Tests/WASITests/IntegrationTests.swift +++ b/Tests/WASITests/IntegrationTests.swift @@ -103,7 +103,6 @@ struct IntegrationTests { #else return [ "WASI Rust tests": [ - "path_link", // path_link is not implemented "symlink_create", // path_symlink accepts an absolute target ] ] From 6b835840a5d423ee07a25ab3cd405e8fb6e3e586 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Sun, 4 Oct 2026 16:00:13 +0000 Subject: [PATCH 10/15] Refuse to create a symlink with an absolute target path_symlink created a symlink to an absolute path, which sandboxed resolution then refuses to follow anyway, and symlink_create expects the creation itself to fail. Answer PERM up front, as wasmtime does. This was the last wasi-testsuite case skipped on Linux and macOS. --- Sources/WASI/WASI.swift | 5 +++++ Tests/WASITests/IntegrationTests.swift | 6 +----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Sources/WASI/WASI.swift b/Sources/WASI/WASI.swift index 515addc63..e9eb7c5a9 100644 --- a/Sources/WASI/WASI.swift +++ b/Sources/WASI/WASI.swift @@ -1420,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 39a27f65c..bfc62bb70 100644 --- a/Tests/WASITests/IntegrationTests.swift +++ b/Tests/WASITests/IntegrationTests.swift @@ -101,11 +101,7 @@ struct IntegrationTests { ], ] #else - return [ - "WASI Rust tests": [ - "symlink_create", // path_symlink accepts an absolute target - ] - ] + return [:] #endif } From 2d07f6b848af45585d62b3dd700fbc3e238b22f2 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Mon, 5 Oct 2026 14:20:03 +0000 Subject: [PATCH 11/15] Give the Windows host file system directory descriptors On Windows a guest could not open a directory: preopens went through _wsopen_s, which refuses directories, so a preopen failed with EACCES, and every directory-relative primitive of the platform layer (openat, fstatat, readlinkat, mkdirat, unlinkat, renameat, linkat, symlinkat, utimensat, readdir) answered NOTSUP. Most of the 53 wasi-testsuite cases skipped on Windows failed for this; 48 of them pass now. Open directories with backup semantics and wrap the HANDLE in a CRT descriptor, so FileDescriptor stays a CRT descriptor everywhere above the platform layer. Win32 has no openat, but the NT layer does: NtCreateFile opens a name relative to a directory HANDLE, and NtSetInformationFile renames or links an open file into one. Every primitive goes through those, never through a path rebuilt from the HANDLE, so a guest renaming directories or planting symlinks from another thread cannot redirect it. The primitives never follow a symlink: the sandbox walker resolves those itself, and an open that lands on one fails with ELOOP, as with O_NOFOLLOW. A name with a backslash or a colon, which Windows would read as a separator or a stream, is refused, and so is a symlink target with either. They give POSIX outcomes where Windows differs: - O_CREAT or O_TRUNC on a directory fails with EISDIR, and an existing symlink is never truncated. - A hard link to a symlink links the symlink. - Deleting and renaming use POSIX semantics, so a name disappears at once even while open, and renaming onto an empty directory replaces it. - `.` cannot be renamed, so a preopen cannot be moved through itself. - Only symlinks and junctions count as symlinks; other reparse points, such as cloud-file placeholders, are ordinary files. Windows records whether a symlink points to a directory, so the symlinkat primitive takes a closure that looks the target up in the sandbox. Creating a symlink by handle needs the symlink privilege; a non-elevated process falls back to CreateSymbolicLinkW on the directory's path, as cap-std does for wasmtime. --- Sources/WASI/CMakeLists.txt | 1 + Sources/WASI/Platform/Directory.swift | 13 +- .../WASI/Platform/PAL/PALErrnoMapping.swift | 9 + .../WASI/Platform/PAL/PALFileDescriptor.swift | 57 +- Sources/WASI/Platform/PAL/PALWindows.swift | 677 ++++++++++++++++++ Tests/WASITests/IntegrationTests.swift | 59 +- Tests/WASITests/WASITests.swift | 61 +- 7 files changed, 810 insertions(+), 67 deletions(-) create mode 100644 Sources/WASI/Platform/PAL/PALWindows.swift 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/Platform/Directory.swift b/Sources/WASI/Platform/Directory.swift index 0a71f380a..4369f3074 100644 --- a/Sources/WASI/Platform/Directory.swift +++ b/Sources/WASI/Platform/Directory.swift @@ -111,7 +111,18 @@ 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 + } } } 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 c3ba67be1..5b25d4d72 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 { @@ -301,12 +303,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 +367,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 +383,10 @@ 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 @@ -404,6 +407,10 @@ struct FileDescriptor: Sendable, Hashable { } } } + #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 @@ -421,6 +428,8 @@ struct FileDescriptor: Sendable, Hashable { } } } + #elseif os(Windows) + try WindowsFileSystem.createHardLink(at: path, in: self, to: newPath, in: newDir) #else throw WASIAbi.Errno.ENOTSUP #endif @@ -433,6 +442,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 @@ -445,14 +456,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 @@ -461,6 +476,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 @@ -477,6 +494,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 @@ -497,6 +516,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 } @@ -529,6 +558,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 @@ -544,6 +575,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 @@ -822,9 +855,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/PALWindows.swift b/Sources/WASI/Platform/PAL/PALWindows.swift new file mode 100644 index 000000000..4481d22cc --- /dev/null +++ b/Sources/WASI/Platform/PAL/PALWindows.swift @@ -0,0 +1,677 @@ +// 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`. + static func attributes(of fd: FileDescriptor) throws -> FileDescriptor.Attributes { + let handle = handle(fd) + let isSymlink = isNameSurrogate(try? reparseTag(of: handle)) + return FileDescriptor.Attributes(windowsFileInformation: try information(of: handle), isSymlink: isSymlink) + } + + /// `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 + } + + // The CRT's _O_APPEND moves to the end of the file before each write. + var crtFlags: CInt = mode == .readOnly ? _O_RDONLY : 0 + if options.contains(.append) { crtFlags |= _O_APPEND } + return try wrap(handle, crtFlags: crtFlags) + } + + 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/Tests/WASITests/IntegrationTests.swift b/Tests/WASITests/IntegrationTests.swift index bfc62bb70..0d4677b6f 100644 --- a/Tests/WASITests/IntegrationTests.swift +++ b/Tests/WASITests/IntegrationTests.swift @@ -41,63 +41,14 @@ struct IntegrationTests { 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", + "pwrite-with-append", // pwrite moves the file offset ], "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", + "fd_flags_set", // fd_fdstat_set_flags cannot turn APPEND off + "path_filestat", // fd_fdstat_get does not report APPEND + "poll_oneoff_stdio", // poll_oneoff on a descriptor is not supported + "stdio", // fd_fdstat_get fails on the NUL device given as stdin ], ] #else diff --git a/Tests/WASITests/WASITests.swift b/Tests/WASITests/WASITests.swift index a3c0eea28..729fee1d8 100644 --- a/Tests/WASITests/WASITests.swift +++ b/Tests/WASITests/WASITests.swift @@ -159,7 +159,7 @@ struct WASITests { } #endif - #if !os(Windows) + #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. @@ -208,6 +208,65 @@ struct WASITests { } } } + + /// 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 From c847668663995cfc0354033766eb199b583b9037 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Sun, 4 Oct 2026 16:47:04 +0000 Subject: [PATCH 12/15] Keep the file offset across pread and pwrite on Windows The CRT offers neither pread nor pwrite, so fd_pread and fd_pwrite seeked to the requested offset and left the descriptor there, while POSIX leaves the offset alone. pwrite-with-append checks that. Seek back to where the descriptor was afterwards. --- Sources/WASI/Platform/PAL/PALFileDescriptor.swift | 9 +++++++-- Tests/WASITests/IntegrationTests.swift | 3 --- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Sources/WASI/Platform/PAL/PALFileDescriptor.swift b/Sources/WASI/Platform/PAL/PALFileDescriptor.swift index 5b25d4d72..27bf57dfc 100644 --- a/Sources/WASI/Platform/PAL/PALFileDescriptor.swift +++ b/Sources/WASI/Platform/PAL/PALFileDescriptor.swift @@ -174,10 +174,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) @@ -196,6 +198,9 @@ 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. + 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 { diff --git a/Tests/WASITests/IntegrationTests.swift b/Tests/WASITests/IntegrationTests.swift index 0d4677b6f..39ead96ca 100644 --- a/Tests/WASITests/IntegrationTests.swift +++ b/Tests/WASITests/IntegrationTests.swift @@ -41,9 +41,6 @@ struct IntegrationTests { static var skipTests: [String: Set] { #if os(Windows) return [ - "WASI C tests": [ - "pwrite-with-append", // pwrite moves the file offset - ], "WASI Rust tests": [ "fd_flags_set", // fd_fdstat_set_flags cannot turn APPEND off "path_filestat", // fd_fdstat_get does not report APPEND From 3027fbf00de3d476c68feefeedd285dd3ddf158e Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Mon, 5 Oct 2026 14:22:37 +0000 Subject: [PATCH 13/15] Keep the status flags of Windows descriptors The CRT has no F_GETFL or F_SETFL, so fd_fdstat_get reported no flags on Windows, where path_filestat expects the APPEND it opened with, and fd_fdstat_set_flags was ignored, so fd_flags_set could not turn APPEND off. Record the open options per descriptor in the platform layer instead, update them on F_SETFL as Linux does (append and nonblocking only), and seek to the end before each write while append is set, as the CRT's own _O_APPEND did. A pipe or console cannot seek but appends anyway, so a failed seek is ignored. --- .../WASI/Platform/PAL/PALFileDescriptor.swift | 22 +++++++++--- Sources/WASI/Platform/PAL/PALWindows.swift | 34 ++++++++++++++++--- Tests/WASITests/IntegrationTests.swift | 2 -- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/Sources/WASI/Platform/PAL/PALFileDescriptor.swift b/Sources/WASI/Platform/PAL/PALFileDescriptor.swift index 27bf57dfc..08c9878b8 100644 --- a/Sources/WASI/Platform/PAL/PALFileDescriptor.swift +++ b/Sources/WASI/Platform/PAL/PALFileDescriptor.swift @@ -144,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) } @@ -166,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) } @@ -198,13 +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. + // 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) @@ -281,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) @@ -295,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) diff --git a/Sources/WASI/Platform/PAL/PALWindows.swift b/Sources/WASI/Platform/PAL/PALWindows.swift index 4481d22cc..655ee1e1d 100644 --- a/Sources/WASI/Platform/PAL/PALWindows.swift +++ b/Sources/WASI/Platform/PAL/PALWindows.swift @@ -314,10 +314,36 @@ throw error } - // The CRT's _O_APPEND moves to the end of the file before each write. - var crtFlags: CInt = mode == .readOnly ? _O_RDONLY : 0 - if options.contains(.append) { crtFlags |= _O_APPEND } - return try wrap(handle, crtFlags: crtFlags) + 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 { diff --git a/Tests/WASITests/IntegrationTests.swift b/Tests/WASITests/IntegrationTests.swift index 39ead96ca..509592f37 100644 --- a/Tests/WASITests/IntegrationTests.swift +++ b/Tests/WASITests/IntegrationTests.swift @@ -42,8 +42,6 @@ struct IntegrationTests { #if os(Windows) return [ "WASI Rust tests": [ - "fd_flags_set", // fd_fdstat_set_flags cannot turn APPEND off - "path_filestat", // fd_fdstat_get does not report APPEND "poll_oneoff_stdio", // poll_oneoff on a descriptor is not supported "stdio", // fd_fdstat_get fails on the NUL device given as stdin ], From e584d26a8ff0bf454d34762371faac7448377533 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Mon, 5 Oct 2026 14:25:04 +0000 Subject: [PATCH 14/15] Stat character devices on Windows GetFileInformationByHandle fails on character devices such as NUL and the console, so fd_fdstat_get and fd_filestat_get failed on stdio redirected from NUL, which the stdio test does after renumbering it. Report such a handle as a character device with no other metadata instead, as fstat would. --- Sources/WASI/Platform/PAL/PALWindows.swift | 16 +++++++++++++--- Tests/WASITests/IntegrationTests.swift | 1 - 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/Sources/WASI/Platform/PAL/PALWindows.swift b/Sources/WASI/Platform/PAL/PALWindows.swift index 655ee1e1d..4790e3765 100644 --- a/Sources/WASI/Platform/PAL/PALWindows.swift +++ b/Sources/WASI/Platform/PAL/PALWindows.swift @@ -187,11 +187,21 @@ info.dwFileAttributes & DWORD(FILE_ATTRIBUTE_DIRECTORY) != 0 } - /// `fstat`. + /// `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) - let isSymlink = isNameSurrogate(try? reparseTag(of: handle)) - return FileDescriptor.Attributes(windowsFileInformation: try information(of: handle), isSymlink: isSymlink) + 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`. diff --git a/Tests/WASITests/IntegrationTests.swift b/Tests/WASITests/IntegrationTests.swift index 509592f37..eba3ded22 100644 --- a/Tests/WASITests/IntegrationTests.swift +++ b/Tests/WASITests/IntegrationTests.swift @@ -43,7 +43,6 @@ struct IntegrationTests { return [ "WASI Rust tests": [ "poll_oneoff_stdio", // poll_oneoff on a descriptor is not supported - "stdio", // fd_fdstat_get fails on the NUL device given as stdin ], ] #else From d8d3df09dfd92733976aecfe94149ec9993837eb Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Sun, 4 Oct 2026 16:52:25 +0000 Subject: [PATCH 15/15] Poll descriptors on Windows poll_oneoff answered NOTSUP for any descriptor subscription on Windows, which has no poll for arbitrary handles, so poll_oneoff_stdio failed. Probe each handle by its kind instead, until one is ready or the clock runs out: 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 once it has pending events; anything is writable. This was the last wasi-testsuite case skipped on any platform, so the skip lists go away. --- Sources/WASI/Platform/PAL/PALPoll.swift | 46 +++++++++++++++++++++++-- Tests/WASITests/IntegrationTests.swift | 34 +----------------- 2 files changed, 44 insertions(+), 36 deletions(-) diff --git a/Sources/WASI/Platform/PAL/PALPoll.swift b/Sources/WASI/Platform/PAL/PALPoll.swift index 73f851c75..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 { @@ -72,10 +76,46 @@ enum PlatformPoll { 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/Tests/WASITests/IntegrationTests.swift b/Tests/WASITests/IntegrationTests.swift index eba3ded22..fa250ed41 100644 --- a/Tests/WASITests/IntegrationTests.swift +++ b/Tests/WASITests/IntegrationTests.swift @@ -34,21 +34,6 @@ struct IntegrationTests { let path: URL let reason: String } - struct SuiteManifest: Codable { - let name: String - } - - static var skipTests: [String: Set] { - #if os(Windows) - return [ - "WASI Rust tests": [ - "poll_oneoff_stdio", // poll_oneoff on a descriptor is not supported - ], - ] - #else - return [:] - #endif - } /// The WASI version whose prebuilt tests we run. The suites also ship /// `wasm32-wasip3` components, which need the component model. @@ -67,9 +52,6 @@ struct IntegrationTests { } 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])! @@ -81,21 +63,7 @@ struct IntegrationTests { } let tests = try FileManager.default.contentsOfDirectory(at: path, includingPropertiesForKeys: nil, options: []) - - // Suite names carry the WASI version, e.g. "WASI C tests [wasm32-wasip1]". - let suiteName = manifest.name.replacingOccurrences(of: " [\(wasiVersion)]", with: "") - let skipTests = Self.skipTests[suiteName] ?? [] - - 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.sorted { $0.path < $1.path } + return tests.filter { $0.pathExtension == "wasm" }.sorted { $0.path < $1.path } } /// A test specification, as described in `doc/specification.md` of