diff --git a/Sources/CSystemExtras/include/clock.h b/Sources/CSystemExtras/include/clock.h index ddcdc6fa..2805291e 100644 --- a/Sources/CSystemExtras/include/clock.h +++ b/Sources/CSystemExtras/include/clock.h @@ -3,3 +3,6 @@ inline static clockid_t csystemextras_monotonic_clockid() { return CLOCK_MONOTONIC; } +inline static clockid_t csystemextras_realtime_clockid() { + return CLOCK_REALTIME; +} diff --git a/Sources/SystemExtras/Clock.swift b/Sources/SystemExtras/Clock.swift index 3a040258..fd8cd516 100644 --- a/Sources/SystemExtras/Clock.swift +++ b/Sources/SystemExtras/Clock.swift @@ -54,6 +54,16 @@ extension Clock { public static var monotonic: Clock { Clock(rawValue: csystemextras_monotonic_clockid()) } #endif + #if SYSTEM_PACKAGE_DARWIN || os(Linux) || os(Android) || os(OpenBSD) || os(FreeBSD) + @_alwaysEmitIntoClient + public static var realtime: Clock { Clock(rawValue: _CLOCK_REALTIME) } + #endif + + #if os(WASI) + @_alwaysEmitIntoClient + public static var realtime: Clock { Clock(rawValue: csystemextras_realtime_clockid()) } + #endif + #if os(OpenBSD) || os(FreeBSD) @_alwaysEmitIntoClient public static var uptime: Clock { Clock(rawValue: _CLOCK_UPTIME) } diff --git a/Sources/SystemExtras/Constants.swift b/Sources/SystemExtras/Constants.swift index 29a81d6a..ebdca9f4 100644 --- a/Sources/SystemExtras/Constants.swift +++ b/Sources/SystemExtras/Constants.swift @@ -138,6 +138,10 @@ internal var _CLOCK_MONOTONIC_RAW: CInterop.ClockId { CLOCK_MONOTONIC_RAW } @_alwaysEmitIntoClient internal var _CLOCK_MONOTONIC: CInterop.ClockId { CLOCK_MONOTONIC } #endif +#if SYSTEM_PACKAGE_DARWIN || os(Linux) || os(Android) || os(OpenBSD) || os(FreeBSD) +@_alwaysEmitIntoClient +internal var _CLOCK_REALTIME: CInterop.ClockId { CLOCK_REALTIME } +#endif #if SYSTEM_PACKAGE_DARWIN @_alwaysEmitIntoClient internal var _CLOCK_UPTIME_RAW: CInterop.ClockId { CLOCK_UPTIME_RAW } diff --git a/Sources/SystemExtras/FileOperations.swift b/Sources/SystemExtras/FileOperations.swift index 7b6f1902..fa0339f6 100644 --- a/Sources/SystemExtras/FileOperations.swift +++ b/Sources/SystemExtras/FileOperations.swift @@ -249,7 +249,10 @@ extension FileDescriptor { @_alwaysEmitIntoClient public var device: UInt64 { - UInt64(rawValue.st_dev) + if (rawValue.st_dev < 0) { + return UInt64(bitPattern: Int64(rawValue.st_dev)) + } + return UInt64(rawValue.st_dev) } @_alwaysEmitIntoClient diff --git a/Sources/WASI/CMakeLists.txt b/Sources/WASI/CMakeLists.txt index 74144a9f..b66bac15 100644 --- a/Sources/WASI/CMakeLists.txt +++ b/Sources/WASI/CMakeLists.txt @@ -6,11 +6,17 @@ add_wasmkit_library(WASI Platform/File.swift Platform/PlatformTypes.swift Platform/SandboxPrimitives.swift + Platform/HostFileSystem.swift + MemoryFileSystem/MemoryFileSystem.swift + MemoryFileSystem/MemoryFSNodes.swift + MemoryFileSystem/MemoryDirEntry.swift + MemoryFileSystem/MemoryFileEntry.swift FileSystem.swift GuestMemorySupport.swift Clock.swift RandomBufferGenerator.swift WASI.swift + WASIBridgeToHost.swift ) target_link_wasmkit_libraries(WASI PUBLIC diff --git a/Sources/WASI/Clock.swift b/Sources/WASI/Clock.swift index 8b096b82..b7e6b597 100644 --- a/Sources/WASI/Clock.swift +++ b/Sources/WASI/Clock.swift @@ -73,11 +73,23 @@ public protocol MonotonicClock { public func now() throws -> WallClock.Duration { var fileTime = FILETIME() - GetSystemTimeAsFileTime(&fileTime) - // > the number of 100-nanosecond intervals since January 1, 1601 (UTC). + // Use GetSystemTimePreciseAsFileTime for better precision + // https://learn.microsoft.com/en-us/windows/win32/api/sysinfoapi/nf-sysinfoapi-getsystemtimepreciseasfiletime + GetSystemTimePreciseAsFileTime(&fileTime) + // FILETIME is 100-nanosecond intervals since 1601-01-01 // https://learn.microsoft.com/en-us/windows/win32/api/minwinbase/ns-minwinbase-filetime - let time = (UInt64(fileTime.dwLowDateTime) | UInt64(fileTime.dwHighDateTime) << 32) / 10 - return (seconds: time / 1_000_000_000, nanoseconds: UInt32(time % 1_000_000_000)) + let intervals = (UInt64(fileTime.dwHighDateTime) << 32) | UInt64(fileTime.dwLowDateTime) + // Convert from Windows epoch (1601) to Unix epoch (1970) + // Epoch offset: 11_644_473_600 seconds * 10_000_000 (100ns intervals per second) = 116_444_736_000_000_000 + let unixEpochOffset: UInt64 = 116_444_736_000_000_000 // 100ns intervals between epochs + guard intervals >= unixEpochOffset else { + // Handle pre-1970 dates (return 0) + return (seconds: 0, nanoseconds: 0) + } + let unixIntervals = intervals - unixEpochOffset + // Convert 100ns intervals to nanoseconds, then to seconds/nanoseconds + let totalNanoseconds = unixIntervals * 100 + return (seconds: totalNanoseconds / 1_000_000_000, nanoseconds: UInt32((totalNanoseconds % 1_000_000_000))) } public func resolution() throws -> WallClock.Duration { @@ -123,15 +135,7 @@ public protocol MonotonicClock { /// A wall clock that uses the system's wall clock. public struct SystemWallClock: WallClock { private var underlying: SystemExtras.Clock { - #if os(Linux) || os(Android) - return .boottime - #elseif os(macOS) || os(iOS) || os(watchOS) || os(tvOS) || os(visionOS) - return .rawMonotonic - #elseif os(OpenBSD) || os(FreeBSD) || os(WASI) - return .monotonic - #else - #error("Unsupported platform") - #endif + return .realtime } public init() {} @@ -140,15 +144,37 @@ public protocol MonotonicClock { let timeSpec = try WASIAbi.Errno.translatingPlatformErrno { try underlying.currentTime() } - return (seconds: UInt64(timeSpec.seconds), nanoseconds: UInt32(timeSpec.nanoseconds)) + // Handle potential negative tv_sec (pre-1970 dates) + let seconds = timeSpec.seconds >= 0 ? UInt64(timeSpec.seconds) : 0 + let nanoseconds = timeSpec.nanoseconds >= 0 ? UInt32(timeSpec.nanoseconds) : 0 + return (seconds: seconds, nanoseconds: nanoseconds) } public func resolution() throws -> WallClock.Duration { let timeSpec = try WASIAbi.Errno.translatingPlatformErrno { try underlying.resolution() } - return (seconds: UInt64(timeSpec.seconds), nanoseconds: UInt32(timeSpec.nanoseconds)) + let seconds = timeSpec.seconds >= 0 ? UInt64(timeSpec.seconds) : 0 + let nanoseconds = timeSpec.nanoseconds >= 0 ? UInt32(timeSpec.nanoseconds) : 0 + return (seconds: seconds, nanoseconds: nanoseconds) } } #endif + +// MARK: - Internal Helper + +extension WASIAbi.Timestamp { + /// Get the current wall clock time in nanoseconds since Unix epoch. + /// This is an internal helper for use within the WASI module. + internal static func currentWallClock() -> WASIAbi.Timestamp { + let clock = SystemWallClock() + do { + let duration = try clock.now() + return WASIAbi.Timestamp(wallClockDuration: duration) + } catch { + // Fallback: return 0 on error + return 0 + } + } +} diff --git a/Sources/WASI/FileSystem.swift b/Sources/WASI/FileSystem.swift index b6f64043..291bd01d 100644 --- a/Sources/WASI/FileSystem.swift +++ b/Sources/WASI/FileSystem.swift @@ -83,6 +83,13 @@ enum FdEntry { return directory } } + + func asFile() -> (any WASIFile)? { + if case .file(let entry) = self { + return entry + } + return nil + } } /// A table that maps file descriptor to actual resource in host environment @@ -120,3 +127,28 @@ struct FdTable { } } } + +/// Content of a file that can be retrieved from the file system. +public enum FileContent { + case bytes([UInt8]) + case handle(FileDescriptor) +} + +/// Protocol for file system implementations used by WASI. +/// +/// This protocol contains WASI-specific implementation details. +protocol FileSystemImplementation: ~Copyable { + /// Preopens a directory and returns a WASIDir implementation. + func preopenDirectory(guestPath: String, hostPath: String) throws -> any WASIDir + + /// Opens a file or directory from a directory file descriptor. + func openAt( + dirFd: any WASIDir, + path: String, + oflags: WASIAbi.Oflags, + fsRightsBase: WASIAbi.Rights, + fsRightsInheriting: WASIAbi.Rights, + fdflags: WASIAbi.Fdflags, + symlinkFollow: Bool + ) throws -> FdEntry +} diff --git a/Sources/WASI/MemoryFileSystem/MemoryDirEntry.swift b/Sources/WASI/MemoryFileSystem/MemoryDirEntry.swift new file mode 100644 index 00000000..77d690a6 --- /dev/null +++ b/Sources/WASI/MemoryFileSystem/MemoryDirEntry.swift @@ -0,0 +1,252 @@ +import SystemExtras +import SystemPackage + +/// A WASIDir implementation backed by an in-memory directory node. +struct MemoryDirEntry: WASIDir { + let preopenPath: String? + let dirNode: MemoryDirectoryNode + let path: String + let fileSystem: MemoryFileSystem + + func attributes() throws -> WASIAbi.Filestat { + let timestamps = dirNode.timestamps + return WASIAbi.Filestat( + dev: 0, ino: 0, filetype: .DIRECTORY, + nlink: 1, size: 0, + atim: timestamps.atim, + mtim: timestamps.mtim, + ctim: timestamps.ctim + ) + } + + func fileType() throws -> WASIAbi.FileType { + return .DIRECTORY + } + + func status() throws -> WASIAbi.Fdflags { + return [] + } + + func setTimes( + atim: WASIAbi.Timestamp, mtim: WASIAbi.Timestamp, + fstFlags: WASIAbi.FstFlags + ) throws { + let now = WASIAbi.Timestamp.currentWallClock() + let newAtim: WASIAbi.Timestamp? + if fstFlags.contains(.ATIM) { + newAtim = atim + } else if fstFlags.contains(.ATIM_NOW) { + newAtim = now + } else { + newAtim = nil + } + + let newMtim: WASIAbi.Timestamp? + if fstFlags.contains(.MTIM) { + newMtim = mtim + } else if fstFlags.contains(.MTIM_NOW) { + newMtim = now + } else { + newMtim = nil + } + + dirNode.setTimes(atim: newAtim, mtim: newMtim) + } + + func advise( + offset: WASIAbi.FileSize, length: WASIAbi.FileSize, advice: WASIAbi.Advice + ) throws { + // No-op for memory filesystem + } + + func close() throws { + // No-op for memory filesystem - no resources to release + } + + func openFile( + symlinkFollow: Bool, + path: String, + oflags: WASIAbi.Oflags, + accessMode: FileAccessMode, + fdflags: WASIAbi.Fdflags + ) throws -> FileDescriptor { + // Memory filesystem doesn't return real file descriptors for this method + // File opening is handled through the WASI bridge's path_open implementation + throw WASIAbi.Errno.ENOTSUP + } + + func createDirectory(atPath path: String) throws { + let fullPath = self.path.hasSuffix("/") ? self.path + path : self.path + "/" + path + try fileSystem.ensureDirectory(at: fullPath) + } + + func removeDirectory(atPath path: String) throws { + try fileSystem.removeNode(in: dirNode, at: path, mustBeDirectory: true) + } + + func removeFile(atPath path: String) throws { + try fileSystem.removeNode(in: dirNode, at: path, mustBeDirectory: false) + } + + func symlink(from sourcePath: String, to destPath: String) throws { + // Symlinks not supported in memory filesystem + throw WASIAbi.Errno.ENOTSUP + } + + func rename(from sourcePath: String, toDir newDir: any WASIDir, to destPath: String) throws { + guard let newMemoryDir = newDir as? MemoryDirEntry else { + throw WASIAbi.Errno.EXDEV + } + + try fileSystem.rename( + from: sourcePath, in: dirNode, + to: destPath, in: newMemoryDir.dirNode + ) + } + + func readEntries(cookie: WASIAbi.DirCookie) throws -> AnyIterator> { + let children = dirNode.listChildren() + + let iterator = children.enumerated() + .dropFirst(Int(cookie)) + .map { (index, name) -> Result in + return Result(catching: { + let childPath = self.path.hasSuffix("/") ? self.path + name : self.path + "/" + name + guard let childNode = fileSystem.lookup(at: childPath) else { + throw WASIAbi.Errno.ENOENT + } + + let fileType: WASIAbi.FileType + switch childNode.type { + case .directory: fileType = .DIRECTORY + case .file: fileType = .REGULAR_FILE + case .characterDevice: fileType = .CHARACTER_DEVICE + } + + let dirent = WASIAbi.Dirent( + dNext: WASIAbi.DirCookie(index + 1), + dIno: 0, + dirNameLen: WASIAbi.DirNameLen(name.utf8.count), + dType: fileType + ) + + return (dirent, name) + }) + } + .makeIterator() + + return AnyIterator(iterator) + } + + func attributes(path: String, symlinkFollow: Bool) throws -> WASIAbi.Filestat { + let fullPath = self.path.hasSuffix("/") ? self.path + path : self.path + "/" + path + guard let node = fileSystem.lookup(at: fullPath) else { + throw WASIAbi.Errno.ENOENT + } + + let fileType: WASIAbi.FileType + var size: WASIAbi.FileSize = 0 + var atim: WASIAbi.Timestamp = 0 + var mtim: WASIAbi.Timestamp = 0 + var ctim: WASIAbi.Timestamp = 0 + + switch node.type { + case .directory: + fileType = .DIRECTORY + if let dirNode = node as? MemoryDirectoryNode { + let timestamps = dirNode.timestamps + atim = timestamps.atim + mtim = timestamps.mtim + ctim = timestamps.ctim + } + case .file: + fileType = .REGULAR_FILE + if let fileNode = node as? MemoryFileNode { + size = WASIAbi.FileSize(fileNode.size) + let timestamps = fileNode.timestamps + atim = timestamps.atim + mtim = timestamps.mtim + ctim = timestamps.ctim + } + case .characterDevice: + fileType = .CHARACTER_DEVICE + } + + return WASIAbi.Filestat( + dev: 0, ino: 0, filetype: fileType, + nlink: 1, size: size, + atim: atim, mtim: mtim, ctim: ctim + ) + } + + func setFilestatTimes( + path: String, + atim: WASIAbi.Timestamp, mtim: WASIAbi.Timestamp, + fstFlags: WASIAbi.FstFlags, symlinkFollow: Bool + ) throws { + let fullPath = self.path.hasSuffix("/") ? self.path + path : self.path + "/" + path + guard let node = fileSystem.lookup(at: fullPath) else { + throw WASIAbi.Errno.ENOENT + } + + let now = WASIAbi.Timestamp.currentWallClock() + let newAtim: WASIAbi.Timestamp? + if fstFlags.contains(.ATIM) { + newAtim = atim + } else if fstFlags.contains(.ATIM_NOW) { + newAtim = now + } else { + newAtim = nil + } + + let newMtim: WASIAbi.Timestamp? + if fstFlags.contains(.MTIM) { + newMtim = mtim + } else if fstFlags.contains(.MTIM_NOW) { + newMtim = now + } else { + newMtim = nil + } + + if let dirNode = node as? MemoryDirectoryNode { + dirNode.setTimes(atim: newAtim, mtim: newMtim) + return + } + + guard let fileNode = node as? MemoryFileNode else { + return + } + + switch fileNode.content { + case .bytes: + fileNode.setTimes(atim: newAtim, mtim: newMtim) + + case .handle(let handle): + let accessTime: FileTime + if fstFlags.contains(.ATIM) { + accessTime = FileTime( + seconds: Int(atim / 1_000_000_000), + nanoseconds: Int(atim % 1_000_000_000) + ) + } else if fstFlags.contains(.ATIM_NOW) { + accessTime = .now + } else { + accessTime = .omit + } + + let modTime: FileTime + if fstFlags.contains(.MTIM) { + modTime = FileTime( + seconds: Int(mtim / 1_000_000_000), + nanoseconds: Int(mtim % 1_000_000_000) + ) + } else if fstFlags.contains(.MTIM_NOW) { + modTime = .now + } else { + modTime = .omit + } + + try handle.setTimes(access: accessTime, modification: modTime) + } + } +} diff --git a/Sources/WASI/MemoryFileSystem/MemoryFSNodes.swift b/Sources/WASI/MemoryFileSystem/MemoryFSNodes.swift new file mode 100644 index 00000000..3fdba405 --- /dev/null +++ b/Sources/WASI/MemoryFileSystem/MemoryFSNodes.swift @@ -0,0 +1,306 @@ +import SystemPackage + +/// Base protocol for all file system nodes in memory. +protocol MemFSNode: AnyObject { + var type: MemFSNodeType { get } +} + +/// Types of file system nodes. +enum MemFSNodeType { + case directory + case file + case characterDevice +} + +/// A directory node in the memory file system. +final class MemoryDirectoryNode: MemFSNode { + let type: MemFSNodeType = .directory + private var children: [String: MemFSNode] = [:] + + private var _atim: WASIAbi.Timestamp + private var _mtim: WASIAbi.Timestamp + private var _ctim: WASIAbi.Timestamp + + init() { + let now = WASIAbi.Timestamp.currentWallClock() + self._atim = now + self._mtim = now + self._ctim = now + } + + var timestamps: (atim: WASIAbi.Timestamp, mtim: WASIAbi.Timestamp, ctim: WASIAbi.Timestamp) { + return (_atim, _mtim, _ctim) + } + + func touchAccessTime() { + _atim = WASIAbi.Timestamp.currentWallClock() + } + + func touchModificationTime() { + let now = WASIAbi.Timestamp.currentWallClock() + _mtim = now + _ctim = now + } + + func setTimes(atim: WASIAbi.Timestamp?, mtim: WASIAbi.Timestamp?) { + let now = WASIAbi.Timestamp.currentWallClock() + if let atim = atim { + _atim = atim + } + if let mtim = mtim { + _mtim = mtim + } + _ctim = now + } + + func getChild(name: String) -> MemFSNode? { + return children[name] + } + + func setChild(name: String, node: MemFSNode) { + children[name] = node + touchModificationTime() + } + + @discardableResult + func removeChild(name: String) -> Bool { + let removed = children.removeValue(forKey: name) != nil + if removed { + touchModificationTime() + } + return removed + } + + func listChildren() -> [String] { + touchAccessTime() + return Array(children.keys).sorted() + } + + func childCount() -> Int { + return children.count + } +} + +/// A regular file node in the memory file system. +final class MemoryFileNode: MemFSNode { + let type: MemFSNodeType = .file + var content: FileContent + + private var _atim: WASIAbi.Timestamp + private var _mtim: WASIAbi.Timestamp + private var _ctim: WASIAbi.Timestamp + + init(content: FileContent) { + self.content = content + let now = WASIAbi.Timestamp.currentWallClock() + self._atim = now + self._mtim = now + self._ctim = now + } + + convenience init(bytes: some Sequence) { + self.init(content: .bytes(Array(bytes))) + } + + convenience init(handle: FileDescriptor) { + self.init(content: .handle(handle)) + } + + var size: Int { + switch content { + case .bytes(let bytes): + return bytes.count + case .handle(let fd): + do { + let attrs = try fd.attributes() + return Int(attrs.size) + } catch { + return 0 + } + } + } + + var timestamps: (atim: WASIAbi.Timestamp, mtim: WASIAbi.Timestamp, ctim: WASIAbi.Timestamp) { + switch content { + case .bytes: + return (_atim, _mtim, _ctim) + case .handle(let fd): + do { + let attrs = try fd.attributes() + let atim = WASIAbi.Timestamp(platformTimeSpec: attrs.accessTime) + let mtim = WASIAbi.Timestamp(platformTimeSpec: attrs.modificationTime) + let ctim = WASIAbi.Timestamp(platformTimeSpec: attrs.creationTime) + return (atim, mtim, ctim) + } catch { + return (0, 0, 0) + } + } + } + + func touchAccessTime() { + if case .bytes = content { + _atim = WASIAbi.Timestamp.currentWallClock() + } + } + + func touchModificationTime() { + if case .bytes = content { + let now = WASIAbi.Timestamp.currentWallClock() + _mtim = now + _ctim = now + } + } + + func setTimes(atim: WASIAbi.Timestamp?, mtim: WASIAbi.Timestamp?) { + if case .bytes = content { + let now = WASIAbi.Timestamp.currentWallClock() + if let atim = atim { + _atim = atim + } + if let mtim = mtim { + _mtim = mtim + } + _ctim = now + } + } +} + +/// A character device node in the memory file system. +final class MemoryCharacterDeviceNode: MemFSNode { + let type: MemFSNodeType = .characterDevice + + enum Kind { + case null + } + + let kind: Kind + + init(kind: Kind) { + self.kind = kind + } +} + +/// A WASIFile implementation for character devices like /dev/null +final class MemoryCharacterDeviceEntry: WASIFile { + let deviceNode: MemoryCharacterDeviceNode + let accessMode: FileAccessMode + + init(deviceNode: MemoryCharacterDeviceNode, accessMode: FileAccessMode) { + self.deviceNode = deviceNode + self.accessMode = accessMode + } + + // MARK: - WASIEntry + + func attributes() throws -> WASIAbi.Filestat { + return WASIAbi.Filestat( + dev: 0, ino: 0, filetype: .CHARACTER_DEVICE, + nlink: 1, size: 0, + atim: 0, mtim: 0, ctim: 0 + ) + } + + func fileType() throws -> WASIAbi.FileType { + return .CHARACTER_DEVICE + } + + func status() throws -> WASIAbi.Fdflags { + return [] + } + + func setTimes( + atim: WASIAbi.Timestamp, mtim: WASIAbi.Timestamp, + fstFlags: WASIAbi.FstFlags + ) throws { + // No-op for character devices + } + + func advise( + offset: WASIAbi.FileSize, length: WASIAbi.FileSize, advice: WASIAbi.Advice + ) throws { + // No-op for character devices + } + + func close() throws { + // No-op for character devices + } + + // MARK: - WASIFile + + func fdStat() throws -> WASIAbi.FdStat { + var fsRightsBase: WASIAbi.Rights = [] + if accessMode.contains(.read) { + fsRightsBase.insert(.FD_READ) + } + if accessMode.contains(.write) { + fsRightsBase.insert(.FD_WRITE) + } + + return WASIAbi.FdStat( + fsFileType: .CHARACTER_DEVICE, + fsFlags: [], + fsRightsBase: fsRightsBase, + fsRightsInheriting: [] + ) + } + + func setFdStatFlags(_ flags: WASIAbi.Fdflags) throws { + // No-op for character devices + } + + func setFilestatSize(_ size: WASIAbi.FileSize) throws { + throw WASIAbi.Errno.EINVAL + } + + func sync() throws { + // No-op for character devices + } + + func datasync() throws { + // No-op for character devices + } + + func tell() throws -> WASIAbi.FileSize { + return 0 + } + + func seek(offset: WASIAbi.FileDelta, whence: WASIAbi.Whence) throws -> WASIAbi.FileSize { + throw WASIAbi.Errno.ESPIPE + } + + func write(vectored buffer: Buffer) throws -> WASIAbi.Size where Buffer.Element == WASIAbi.IOVec { + guard accessMode.contains(.write) else { + throw WASIAbi.Errno.EBADF + } + + switch deviceNode.kind { + case .null: + var totalBytes: UInt32 = 0 + for iovec in buffer { + iovec.withHostBufferPointer { bufferPtr in + totalBytes += UInt32(bufferPtr.count) + } + } + return totalBytes + } + } + + func pwrite(vectored buffer: Buffer, offset: WASIAbi.FileSize) throws -> WASIAbi.Size where Buffer.Element == WASIAbi.IOVec { + throw WASIAbi.Errno.ESPIPE + } + + func read(into buffer: Buffer) throws -> WASIAbi.Size where Buffer.Element == WASIAbi.IOVec { + guard accessMode.contains(.read) else { + throw WASIAbi.Errno.EBADF + } + + switch deviceNode.kind { + case .null: + return 0 + } + } + + func pread(into buffer: Buffer, offset: WASIAbi.FileSize) throws -> WASIAbi.Size where Buffer.Element == WASIAbi.IOVec { + throw WASIAbi.Errno.ESPIPE + } +} diff --git a/Sources/WASI/MemoryFileSystem/MemoryFileEntry.swift b/Sources/WASI/MemoryFileSystem/MemoryFileEntry.swift new file mode 100644 index 00000000..53320223 --- /dev/null +++ b/Sources/WASI/MemoryFileSystem/MemoryFileEntry.swift @@ -0,0 +1,367 @@ +import SystemExtras +import SystemPackage + +/// A WASIFile implementation for regular files in the memory file system. +final class MemoryFileEntry: WASIFile { + let fileNode: MemoryFileNode + let accessMode: FileAccessMode + var position: Int + + init(fileNode: MemoryFileNode, accessMode: FileAccessMode, position: Int = 0) { + self.fileNode = fileNode + self.accessMode = accessMode + self.position = position + } + + // MARK: - WASIEntry + + func attributes() throws -> WASIAbi.Filestat { + let timestamps = fileNode.timestamps + return WASIAbi.Filestat( + dev: 0, ino: 0, filetype: .REGULAR_FILE, + nlink: 1, size: WASIAbi.FileSize(fileNode.size), + atim: timestamps.atim, + mtim: timestamps.mtim, + ctim: timestamps.ctim + ) + } + + func fileType() throws -> WASIAbi.FileType { + return .REGULAR_FILE + } + + func status() throws -> WASIAbi.Fdflags { + return [] + } + + func setTimes( + atim: WASIAbi.Timestamp, mtim: WASIAbi.Timestamp, + fstFlags: WASIAbi.FstFlags + ) throws { + switch fileNode.content { + case .bytes: + let now = WASIAbi.Timestamp.currentWallClock() + let newAtim: WASIAbi.Timestamp? + if fstFlags.contains(.ATIM) { + newAtim = atim + } else if fstFlags.contains(.ATIM_NOW) { + newAtim = now + } else { + newAtim = nil + } + + let newMtim: WASIAbi.Timestamp? + if fstFlags.contains(.MTIM) { + newMtim = mtim + } else if fstFlags.contains(.MTIM_NOW) { + newMtim = now + } else { + newMtim = nil + } + + fileNode.setTimes(atim: newAtim, mtim: newMtim) + + case .handle(let handle): + let accessTime: FileTime + if fstFlags.contains(.ATIM) { + accessTime = FileTime( + seconds: Int(atim / 1_000_000_000), + nanoseconds: Int(atim % 1_000_000_000) + ) + } else if fstFlags.contains(.ATIM_NOW) { + accessTime = .now + } else { + accessTime = .omit + } + + let modTime: FileTime + if fstFlags.contains(.MTIM) { + modTime = FileTime( + seconds: Int(mtim / 1_000_000_000), + nanoseconds: Int(mtim % 1_000_000_000) + ) + } else if fstFlags.contains(.MTIM_NOW) { + modTime = .now + } else { + modTime = .omit + } + + try handle.setTimes(access: accessTime, modification: modTime) + } + } + + func advise( + offset: WASIAbi.FileSize, length: WASIAbi.FileSize, advice: WASIAbi.Advice + ) throws { + // No-op for memory filesystem + } + + func close() throws { + // No-op for memory filesystem - no resources to release + } + + // MARK: - WASIFile + + func fdStat() throws -> WASIAbi.FdStat { + var fsRightsBase: WASIAbi.Rights = [] + if accessMode.contains(.read) { + fsRightsBase.insert(.FD_READ) + fsRightsBase.insert(.FD_SEEK) + fsRightsBase.insert(.FD_TELL) + } + if accessMode.contains(.write) { + fsRightsBase.insert(.FD_WRITE) + } + + return WASIAbi.FdStat( + fsFileType: .REGULAR_FILE, + fsFlags: [], + fsRightsBase: fsRightsBase, + fsRightsInheriting: [] + ) + } + + func setFdStatFlags(_ flags: WASIAbi.Fdflags) throws { + // No-op for memory filesystem + } + + func setFilestatSize(_ size: WASIAbi.FileSize) throws { + switch fileNode.content { + case .bytes(var bytes): + let newSize = Int(size) + if newSize < bytes.count { + bytes = Array(bytes.prefix(newSize)) + } else if newSize > bytes.count { + bytes.append(contentsOf: Array(repeating: 0, count: newSize - bytes.count)) + } + fileNode.content = .bytes(bytes) + fileNode.touchModificationTime() + + case .handle(let handle): + try handle.truncate(size: Int64(size)) + } + } + + func sync() throws { + if case .handle(let handle) = fileNode.content { + try handle.sync() + } + } + + func datasync() throws { + if case .handle(let handle) = fileNode.content { + try handle.datasync() + } + } + + func tell() throws -> WASIAbi.FileSize { + return WASIAbi.FileSize(position) + } + + func seek(offset: WASIAbi.FileDelta, whence: WASIAbi.Whence) throws -> WASIAbi.FileSize { + let newPosition: Int + + switch fileNode.content { + case .bytes(let bytes): + switch whence { + case .SET: + newPosition = Int(offset) + case .CUR: + newPosition = position + Int(offset) + case .END: + newPosition = bytes.count + Int(offset) + } + + case .handle(let handle): + let platformWhence: FileDescriptor.SeekOrigin + switch whence { + case .SET: + platformWhence = .start + case .CUR: + platformWhence = .current + case .END: + platformWhence = .end + } + let result = try handle.seek(offset: offset, from: platformWhence) + position = Int(result) + return WASIAbi.FileSize(result) + } + + guard newPosition >= 0 else { + throw WASIAbi.Errno.EINVAL + } + + position = newPosition + return WASIAbi.FileSize(newPosition) + } + + func write(vectored buffer: Buffer) throws -> WASIAbi.Size where Buffer.Element == WASIAbi.IOVec { + guard accessMode.contains(.write) else { + throw WASIAbi.Errno.EBADF + } + + var totalWritten: UInt32 = 0 + + switch fileNode.content { + case .bytes(var bytes): + var currentPosition = position + for iovec in buffer { + iovec.withHostBufferPointer { bufferPtr in + let bytesToWrite = bufferPtr.count + let requiredSize = currentPosition + bytesToWrite + + if requiredSize > bytes.count { + bytes.append(contentsOf: Array(repeating: 0, count: requiredSize - bytes.count)) + } + + bytes.replaceSubrange(currentPosition..<(currentPosition + bytesToWrite), with: bufferPtr) + currentPosition += bytesToWrite + totalWritten += UInt32(bytesToWrite) + } + } + fileNode.content = .bytes(bytes) + position = currentPosition + fileNode.touchModificationTime() + + case .handle(let handle): + var currentOffset = Int64(position) + for iovec in buffer { + let nwritten = try iovec.withHostBufferPointer { bufferPtr in + try handle.writeAll(toAbsoluteOffset: currentOffset, bufferPtr) + } + currentOffset += Int64(nwritten) + totalWritten += UInt32(nwritten) + } + position = Int(currentOffset) + } + + return totalWritten + } + + func pwrite(vectored buffer: Buffer, offset: WASIAbi.FileSize) throws -> WASIAbi.Size where Buffer.Element == WASIAbi.IOVec { + guard accessMode.contains(.write) else { + throw WASIAbi.Errno.EBADF + } + + var totalWritten: UInt32 = 0 + + switch fileNode.content { + case .bytes(var bytes): + var currentOffset = Int(offset) + for iovec in buffer { + iovec.withHostBufferPointer { bufferPtr in + let bytesToWrite = bufferPtr.count + let requiredSize = currentOffset + bytesToWrite + + if requiredSize > bytes.count { + bytes.append(contentsOf: Array(repeating: 0, count: requiredSize - bytes.count)) + } + + bytes.replaceSubrange(currentOffset..<(currentOffset + bytesToWrite), with: bufferPtr) + currentOffset += bytesToWrite + totalWritten += UInt32(bytesToWrite) + } + } + fileNode.content = .bytes(bytes) + fileNode.touchModificationTime() + + case .handle(let handle): + var currentOffset = Int64(offset) + for iovec in buffer { + let nwritten = try iovec.withHostBufferPointer { bufferPtr in + try handle.writeAll(toAbsoluteOffset: currentOffset, bufferPtr) + } + currentOffset += Int64(nwritten) + totalWritten += UInt32(nwritten) + } + } + + return totalWritten + } + + func read(into buffer: Buffer) throws -> WASIAbi.Size where Buffer.Element == WASIAbi.IOVec { + guard accessMode.contains(.read) else { + throw WASIAbi.Errno.EBADF + } + + var totalRead: UInt32 = 0 + + switch fileNode.content { + case .bytes(let bytes): + var currentPosition = position + for iovec in buffer { + iovec.withHostBufferPointer { bufferPtr in + let available = max(0, bytes.count - currentPosition) + let toRead = min(bufferPtr.count, available) + + guard toRead > 0 else { return } + + bytes.withUnsafeBytes { contentBytes in + let sourcePtr = contentBytes.baseAddress!.advanced(by: currentPosition) + bufferPtr.baseAddress!.copyMemory(from: sourcePtr, byteCount: toRead) + } + + currentPosition += toRead + totalRead += UInt32(toRead) + } + } + position = currentPosition + fileNode.touchAccessTime() + + case .handle(let handle): + var currentOffset = Int64(position) + for iovec in buffer { + let nread = try iovec.withHostBufferPointer { bufferPtr in + try handle.read(fromAbsoluteOffset: currentOffset, into: bufferPtr) + } + currentOffset += Int64(nread) + totalRead += UInt32(nread) + } + position = Int(currentOffset) + } + + return totalRead + } + + func pread(into buffer: Buffer, offset: WASIAbi.FileSize) throws -> WASIAbi.Size where Buffer.Element == WASIAbi.IOVec { + guard accessMode.contains(.read) else { + throw WASIAbi.Errno.EBADF + } + + var totalRead: UInt32 = 0 + + switch fileNode.content { + case .bytes(let bytes): + var currentOffset = Int(offset) + for iovec in buffer { + iovec.withHostBufferPointer { bufferPtr in + let available = max(0, bytes.count - currentOffset) + let toRead = min(bufferPtr.count, available) + + guard toRead > 0 else { return } + + bytes.withUnsafeBytes { contentBytes in + let sourcePtr = contentBytes.baseAddress!.advanced(by: currentOffset) + bufferPtr.baseAddress!.copyMemory(from: sourcePtr, byteCount: toRead) + } + + currentOffset += toRead + totalRead += UInt32(toRead) + } + } + fileNode.touchAccessTime() + + case .handle(let handle): + var currentOffset = Int64(offset) + for iovec in buffer { + let nread = try iovec.withHostBufferPointer { bufferPtr in + try handle.read(fromAbsoluteOffset: currentOffset, into: bufferPtr) + } + currentOffset += Int64(nread) + totalRead += UInt32(nread) + } + } + + return totalRead + } +} diff --git a/Sources/WASI/MemoryFileSystem/MemoryFileSystem.swift b/Sources/WASI/MemoryFileSystem/MemoryFileSystem.swift new file mode 100644 index 00000000..9db3d00f --- /dev/null +++ b/Sources/WASI/MemoryFileSystem/MemoryFileSystem.swift @@ -0,0 +1,454 @@ +import SystemPackage + +/// An in-memory file system implementation for WASI environments. +/// +/// This provides a complete file system that exists entirely in memory, useful for +/// sandboxed environments or testing scenarios where host file system access is not desired. +/// +/// Supports both in-memory byte arrays and file descriptor handles. +/// +/// Example usage: +/// ```swift +/// let fs = try MemoryFileSystem() +/// try fs.ensureDirectory(at: "/") +/// try fs.addFile(at: "/hello.txt", content: "Hello, world!") +/// +/// // Or add a file handle +/// let fd = try FileDescriptor.open("/path/to/file", .readOnly) +/// try fs.addFile(at: "/mounted.txt", handle: fd) +/// ``` +public final class MemoryFileSystem: FileSystemImplementation { + private static let rootPath = "/" + + private var root: MemoryDirectoryNode + + /// Creates a new in-memory file system. + public init() throws { + self.root = MemoryDirectoryNode() + } + + // MARK: - Public API + + /// Adds a file to the file system with the given byte content. + /// + /// - Parameters: + /// - path: The path where the file should be created + /// - content: The file content as a sequence of bytes + public func addFile(at path: String, content: some Sequence) throws { + let normalized = normalizePath(path) + let (parentPath, fileName) = try splitPath(normalized) + + let parent = try ensureDirectory(at: parentPath) + parent.setChild(name: fileName, node: MemoryFileNode(bytes: content)) + } + + /// Adds a file to the file system with the given string content. + /// + /// - Parameters: + /// - path: The path where the file should be created + /// - content: The file content as string (converted to UTF-8) + public func addFile(at path: String, content: String) throws { + try addFile(at: path, content: content.utf8) + } + + /// Adds a file to the file system backed by a file descriptor. + /// + /// - Parameters: + /// - path: The path where the file should be created + /// - handle: The file descriptor handle + public func addFile(at path: String, handle: FileDescriptor) throws { + let normalized = normalizePath(path) + let (parentPath, fileName) = try splitPath(normalized) + + let parent = try ensureDirectory(at: parentPath) + parent.setChild(name: fileName, node: MemoryFileNode(handle: handle)) + } + + /// Gets the content of a file at the specified path. + /// + /// - Parameter path: The path of the file to retrieve + /// - Returns: The file content + public func getFile(at path: String) throws -> FileContent { + guard let node = lookup(at: path) else { + throw WASIAbi.Errno.ENOENT + } + + guard let fileNode = node as? MemoryFileNode else { + throw WASIAbi.Errno.EISDIR + } + + return fileNode.content + } + + /// Removes a file from the file system. + /// + /// - Parameter path: The path of the file to remove + public func removeFile(at path: String) throws { + let normalized = normalizePath(path) + let (parentPath, fileName) = try splitPath(normalized) + + guard let parent = lookup(at: parentPath) as? MemoryDirectoryNode else { + throw WASIAbi.Errno.ENOENT + } + + guard parent.removeChild(name: fileName) else { + throw WASIAbi.Errno.ENOENT + } + } + + // MARK: - FileSystemImplementation (WASI API) + + func preopenDirectory(guestPath: String, hostPath: String) throws -> any WASIDir { + let node = try ensureDirectory(at: guestPath) + + return MemoryDirEntry( + preopenPath: guestPath, + dirNode: node, + path: guestPath, + fileSystem: self + ) + } + + func openAt( + dirFd: any WASIDir, + path: String, + oflags: WASIAbi.Oflags, + fsRightsBase: WASIAbi.Rights, + fsRightsInheriting: WASIAbi.Rights, + fdflags: WASIAbi.Fdflags, + symlinkFollow: Bool + ) throws -> FdEntry { + guard let memoryDir = dirFd as? MemoryDirEntry else { + throw WASIAbi.Errno.EBADF + } + + let fullPath = memoryDir.path.hasSuffix("/") ? memoryDir.path + path : memoryDir.path + "/" + path + + var node = resolve(from: memoryDir.dirNode, at: memoryDir.path, path: path) + + if node != nil { + if oflags.contains(.EXCL) && oflags.contains(.CREAT) { + throw WASIAbi.Errno.EEXIST + } + } else { + if oflags.contains(.CREAT) { + node = try createFile(in: memoryDir.dirNode, at: path, oflags: oflags) + } else { + throw WASIAbi.Errno.ENOENT + } + } + + guard let resolvedNode = node else { + throw WASIAbi.Errno.ENOENT + } + + if oflags.contains(.DIRECTORY) { + guard resolvedNode.type == .directory else { + throw WASIAbi.Errno.ENOTDIR + } + } + + // Handle directory nodes + if resolvedNode.type == .directory { + guard let dirNode = resolvedNode as? MemoryDirectoryNode else { + throw WASIAbi.Errno.ENOTDIR + } + return .directory( + MemoryDirEntry( + preopenPath: nil, + dirNode: dirNode, + path: fullPath, + fileSystem: self + )) + } + + // Handle regular file nodes + if resolvedNode.type == .file { + guard let fileNode = resolvedNode as? MemoryFileNode else { + throw WASIAbi.Errno.EBADF + } + + if oflags.contains(.TRUNC) && fsRightsBase.contains(.FD_WRITE) { + fileNode.content = .bytes([]) + } + + var accessMode: FileAccessMode = [] + if fsRightsBase.contains(.FD_READ) { + accessMode.insert(.read) + } + if fsRightsBase.contains(.FD_WRITE) { + accessMode.insert(.write) + } + + return .file(MemoryFileEntry(fileNode: fileNode, accessMode: accessMode, position: 0)) + } + if resolvedNode.type == .characterDevice { + guard let deviceNode = resolvedNode as? MemoryCharacterDeviceNode else { + throw WASIAbi.Errno.EBADF + } + + var accessMode: FileAccessMode = [] + if fsRightsBase.contains(.FD_READ) { + accessMode.insert(.read) + } + if fsRightsBase.contains(.FD_WRITE) { + accessMode.insert(.write) + } + + return .file(MemoryCharacterDeviceEntry(deviceNode: deviceNode, accessMode: accessMode)) + } + throw WASIAbi.Errno.ENOTSUP + } + + // MARK: - File Operations + + func lookup(at path: String) -> MemFSNode? { + let normalized = normalizePath(path) + + if normalized == Self.rootPath { + return root + } + + let components = normalized.split(separator: "/").map(String.init) + var current: MemFSNode = root + + for component in components { + guard let dir = current as? MemoryDirectoryNode else { + return nil + } + guard let next = dir.getChild(name: component) else { + return nil + } + current = next + } + + return current + } + + func resolve(from directory: MemoryDirectoryNode, at directoryPath: String, path relativePath: String) -> MemFSNode? { + if relativePath.isEmpty { + return directory + } + + if relativePath.hasPrefix("/") { + return lookup(at: relativePath) + } + + let fullPath: String + if directoryPath == Self.rootPath { + fullPath = Self.rootPath + relativePath + } else { + fullPath = directoryPath + "/" + relativePath + } + + let components = fullPath.split(separator: "/").map(String.init) + var stack: [String] = [] + + for component in components { + if component == "." { + continue + } else if component == ".." { + if !stack.isEmpty { + stack.removeLast() + } + } else { + stack.append(component) + } + } + + let resolvedPath = stack.isEmpty ? Self.rootPath : Self.rootPath + stack.joined(separator: "/") + return lookup(at: resolvedPath) + } + + @discardableResult + func ensureDirectory(at path: String) throws -> MemoryDirectoryNode { + let normalized = normalizePath(path) + + if normalized == Self.rootPath { + return root + } + + let components = normalized.split(separator: "/").map(String.init) + var current = root + + for component in components { + if let existing = current.getChild(name: component) { + guard let dir = existing as? MemoryDirectoryNode else { + throw WASIAbi.Errno.ENOTDIR + } + current = dir + } else { + let newDir = MemoryDirectoryNode() + current.setChild(name: component, node: newDir) + current = newDir + } + } + + return current + } + + private func validateRelativePath(_ path: String) throws { + guard !path.isEmpty && !path.hasPrefix("/") else { + throw WASIAbi.Errno.EINVAL + } + } + + private func traverseToParent(from directory: MemoryDirectoryNode, components: [String]) throws -> MemoryDirectoryNode { + var current = directory + for component in components { + if let existing = current.getChild(name: component) { + guard let dir = existing as? MemoryDirectoryNode else { + throw WASIAbi.Errno.ENOTDIR + } + current = dir + } else { + let newDir = MemoryDirectoryNode() + current.setChild(name: component, node: newDir) + current = newDir + } + } + return current + } + + @discardableResult + func createFile(in directory: MemoryDirectoryNode, at relativePath: String, oflags: WASIAbi.Oflags) throws -> MemoryFileNode { + try validateRelativePath(relativePath) + + let components = relativePath.split(separator: "/").map(String.init) + guard let fileName = components.last else { + throw WASIAbi.Errno.EINVAL + } + + let parentDir = try traverseToParent(from: directory, components: Array(components.dropLast())) + + if let existing = parentDir.getChild(name: fileName) { + guard let fileNode = existing as? MemoryFileNode else { + throw WASIAbi.Errno.EISDIR + } + if oflags.contains(.TRUNC) { + fileNode.content = .bytes([]) + } + return fileNode + } else { + let fileNode = MemoryFileNode(bytes: []) + parentDir.setChild(name: fileName, node: fileNode) + return fileNode + } + } + + func removeNode(in directory: MemoryDirectoryNode, at relativePath: String, mustBeDirectory: Bool) throws { + try validateRelativePath(relativePath) + + let components = relativePath.split(separator: "/").map(String.init) + guard let fileName = components.last else { + throw WASIAbi.Errno.EINVAL + } + + var current = directory + for component in components.dropLast() { + guard let next = current.getChild(name: component) as? MemoryDirectoryNode else { + throw WASIAbi.Errno.ENOENT + } + current = next + } + + guard let node = current.getChild(name: fileName) else { + throw WASIAbi.Errno.ENOENT + } + + if mustBeDirectory { + guard let dirNode = node as? MemoryDirectoryNode else { + throw WASIAbi.Errno.ENOTDIR + } + if dirNode.childCount() > 0 { + throw WASIAbi.Errno.ENOTEMPTY + } + } else { + if node.type == .directory { + throw WASIAbi.Errno.EISDIR + } + } + + current.removeChild(name: fileName) + } + + func rename(from sourcePath: String, in sourceDir: MemoryDirectoryNode, to destPath: String, in destDir: MemoryDirectoryNode) throws { + guard let sourceNode = resolve(from: sourceDir, at: "", path: sourcePath) else { + throw WASIAbi.Errno.ENOENT + } + + let destComponents = destPath.split(separator: "/").map(String.init) + guard let destFileName = destComponents.last else { + throw WASIAbi.Errno.EINVAL + } + + let destParentDir = try traverseToParent(from: destDir, components: Array(destComponents.dropLast())) + + let sourceComponents = sourcePath.split(separator: "/").map(String.init) + guard let sourceFileName = sourceComponents.last else { + throw WASIAbi.Errno.EINVAL + } + + var sourceParentDir = sourceDir + for component in sourceComponents.dropLast() { + guard let next = sourceParentDir.getChild(name: component) as? MemoryDirectoryNode else { + throw WASIAbi.Errno.ENOENT + } + sourceParentDir = next + } + + sourceParentDir.removeChild(name: sourceFileName) + destParentDir.setChild(name: destFileName, node: sourceNode) + } + + private func normalizePath(_ path: String) -> String { + if path.isEmpty { + return Self.rootPath + } + + var cleaned = "" + var lastWasSlash = false + for char in path.hasPrefix("/") ? path : "/\(path)" { + if char == "/" { + if !lastWasSlash { + cleaned.append(char) + } + lastWasSlash = true + } else { + cleaned.append(char) + lastWasSlash = false + } + } + + if cleaned == Self.rootPath { + return cleaned + } + + if cleaned.hasSuffix("/") { + return String(cleaned.dropLast()) + } + + return cleaned + } + + private func splitPath(_ path: String) throws -> (parent: String, name: String) { + let normalized = normalizePath(path) + + guard normalized != Self.rootPath else { + throw WASIAbi.Errno.EINVAL + } + + let components = normalized.split(separator: "/").map(String.init) + guard let fileName = components.last else { + throw WASIAbi.Errno.EINVAL + } + + if components.count == 1 { + return (Self.rootPath, fileName) + } + + let parentComponents = components.dropLast() + let parentPath = Self.rootPath + parentComponents.joined(separator: "/") + return (parentPath, fileName) + } +} diff --git a/Sources/WASI/Platform/HostFileSystem.swift b/Sources/WASI/Platform/HostFileSystem.swift new file mode 100644 index 00000000..0258f3f2 --- /dev/null +++ b/Sources/WASI/Platform/HostFileSystem.swift @@ -0,0 +1,95 @@ +import SystemPackage + +#if os(macOS) || os(iOS) || os(watchOS) || os(tvOS) || os(visionOS) + import Darwin +#elseif canImport(Glibc) + import Glibc +#elseif canImport(Musl) + import Musl +#elseif canImport(Android) + import Android +#elseif os(Windows) + import ucrt +#elseif os(WASI) + import WASILibc +#else + #error("Unsupported Platform") +#endif + +/// A file system implementation that directly accesses the host operating system's file system. +/// +/// This implementation provides access to actual files and directories on the host system. +final class HostFileSystem: FileSystemImplementation { + + /// Creates a new host file system. + init() { + } + + // MARK: - FileSystemImplementation (WASI API) + + func preopenDirectory(guestPath: String, hostPath: String) throws -> any WASIDir { + #if os(Windows) || os(WASI) + let fd = try FileDescriptor.open(FilePath(hostPath), .readWrite) + #else + let fd = try hostPath.withCString { cHostPath in + let fd = open(cHostPath, O_DIRECTORY) + if fd < 0 { + let errno = errno + throw WASIError(description: "Failed to open preopen path '\(hostPath)': \(String(cString: strerror(errno)))") + } + return FileDescriptor(rawValue: fd) + } + #endif + + guard try fd.attributes().fileType.isDirectory else { + throw WASIAbi.Errno.ENOTDIR + } + + return DirEntry(preopenPath: guestPath, fd: fd) + } + + func openAt( + dirFd: any WASIDir, + path: String, + oflags: WASIAbi.Oflags, + fsRightsBase: WASIAbi.Rights, + fsRightsInheriting: WASIAbi.Rights, + fdflags: WASIAbi.Fdflags, + symlinkFollow: Bool + ) throws -> FdEntry { + #if os(Windows) + throw WASIAbi.Errno.ENOTSUP + #else + var accessMode: FileAccessMode = [] + if fsRightsBase.contains(.FD_READ) { + accessMode.insert(.read) + } + if fsRightsBase.contains(.FD_WRITE) { + accessMode.insert(.write) + } + + let hostFd = try dirFd.openFile( + symlinkFollow: symlinkFollow, + path: path, + oflags: oflags, + accessMode: accessMode, + fdflags: fdflags + ) + + let actualFileType = try hostFd.attributes().fileType + if oflags.contains(.DIRECTORY), actualFileType != .directory { + throw WASIAbi.Errno.ENOTDIR + } + + if actualFileType == .directory { + return .directory(DirEntry(preopenPath: nil, fd: hostFd)) + } else { + return .file(RegularFileEntry(fd: hostFd, accessMode: accessMode)) + } + #endif + } + + func createStdioFile(fd: FileDescriptor, accessMode: FileAccessMode) -> any WASIFile { + return StdioFileEntry(fd: fd, accessMode: accessMode) + } +} diff --git a/Sources/WASI/WASI.swift b/Sources/WASI/WASI.swift index 5bd648ce..98cbf952 100644 --- a/Sources/WASI/WASI.swift +++ b/Sources/WASI/WASI.swift @@ -18,210 +18,6 @@ import WasmTypes #error("Unsupported Platform") #endif -protocol WASI { - /// Reads command-line argument data. - /// - Parameters: - /// - argv: Pointer to an array of argument strings to be written - /// - argvBuffer: Pointer to a buffer of argument strings to be written - func args_get( - argv: UnsafeGuestPointer>, - argvBuffer: UnsafeGuestPointer - ) - - /// Return command-line argument data sizes. - /// - Returns: Tuple of number of arguments and required buffer size - func args_sizes_get() -> (WASIAbi.Size, WASIAbi.Size) - - /// Read environment variable data. - func environ_get( - environ: UnsafeGuestPointer>, - environBuffer: UnsafeGuestPointer - ) - - /// Return environment variable data sizes. - /// - Returns: Tuple of number of environment variables and required buffer size - func environ_sizes_get() -> (WASIAbi.Size, WASIAbi.Size) - - /// Return the resolution of a clock. - func clock_res_get(id: WASIAbi.ClockId) throws -> WASIAbi.Timestamp - - /// Return the time value of a clock. - func clock_time_get( - id: WASIAbi.ClockId, precision: WASIAbi.Timestamp - ) throws -> WASIAbi.Timestamp - - /// Provide file advisory information on a file descriptor. - func fd_advise( - fd: WASIAbi.Fd, offset: WASIAbi.FileSize, - length: WASIAbi.FileSize, advice: WASIAbi.Advice - ) throws - - /// Force the allocation of space in a file. - func fd_allocate(fd: WASIAbi.Fd, offset: WASIAbi.FileSize, length: WASIAbi.FileSize) throws - - /// Close a file descriptor. - func fd_close(fd: WASIAbi.Fd) throws - - /// Synchronize the data of a file to disk. - func fd_datasync(fd: WASIAbi.Fd) throws - - /// Get the attributes of a file descriptor. - /// - Parameter fileDescriptor: File descriptor to get attribute. - func fd_fdstat_get(fileDescriptor: UInt32) throws -> WASIAbi.FdStat - - /// Adjust the flags associated with a file descriptor. - func fd_fdstat_set_flags(fd: WASIAbi.Fd, flags: WASIAbi.Fdflags) throws - - /// Adjust the rights associated with a file descriptor. - func fd_fdstat_set_rights( - fd: WASIAbi.Fd, - fsRightsBase: WASIAbi.Rights, - fsRightsInheriting: WASIAbi.Rights - ) throws - - /// Return the attributes of an open file. - func fd_filestat_get(fd: WASIAbi.Fd) throws -> WASIAbi.Filestat - - /// Adjust the size of an open file. If this increases the file's size, the extra bytes are filled with zeros. - func fd_filestat_set_size(fd: WASIAbi.Fd, size: WASIAbi.FileSize) throws - - /// Adjust the timestamps of an open file or directory. - func fd_filestat_set_times( - fd: WASIAbi.Fd, - atim: WASIAbi.Timestamp, - mtim: WASIAbi.Timestamp, - fstFlags: WASIAbi.FstFlags - ) throws - - /// Read from a file descriptor, without using and updating the file descriptor's offset. - func fd_pread( - fd: WASIAbi.Fd, iovs: UnsafeGuestBufferPointer, - offset: WASIAbi.FileSize - ) throws -> WASIAbi.Size - - /// Return a description of the given preopened file descriptor. - func fd_prestat_get(fd: WASIAbi.Fd) throws -> WASIAbi.Prestat - - /// Return a directory name of the given preopened file descriptor - func fd_prestat_dir_name(fd: WASIAbi.Fd, path: UnsafeGuestPointer, maxPathLength: WASIAbi.Size) throws - - /// Write to a file descriptor, without using and updating the file descriptor's offset. - func fd_pwrite( - fd: WASIAbi.Fd, iovs: UnsafeGuestBufferPointer, - offset: WASIAbi.FileSize - ) throws -> WASIAbi.Size - - /// Read from a file descriptor. - func fd_read( - fd: WASIAbi.Fd, iovs: UnsafeGuestBufferPointer - ) throws -> WASIAbi.Size - - /// Read directory entries from a directory. - func fd_readdir( - fd: WASIAbi.Fd, - buffer: UnsafeGuestBufferPointer, - cookie: WASIAbi.DirCookie - ) throws -> WASIAbi.Size - - /// Atomically replace a file descriptor by renumbering another file descriptor. - func fd_renumber(fd: WASIAbi.Fd, to toFd: WASIAbi.Fd) throws - - /// Move the offset of a file descriptor. - func fd_seek(fd: WASIAbi.Fd, offset: WASIAbi.FileDelta, whence: WASIAbi.Whence) throws -> WASIAbi.FileSize - - /// Synchronize the data and metadata of a file to disk. - func fd_sync(fd: WASIAbi.Fd) throws - - /// Return the current offset of a file descriptor. - func fd_tell(fd: WASIAbi.Fd) throws -> WASIAbi.FileSize - - /// POSIX `writev` equivalent. - /// - Parameters: - /// - fileDescriptor: File descriptor to write to. - /// - ioVectors: Buffer pointer to an array of byte buffers to write. - /// - Returns: Number of bytes written. - func fd_write( - fileDescriptor: WASIAbi.Fd, - ioVectors: UnsafeGuestBufferPointer - ) throws -> UInt32 - - /// Create a directory. - func path_create_directory( - dirFd: WASIAbi.Fd, - path: String - ) throws - - /// Return the attributes of a file or directory. - func path_filestat_get( - dirFd: WASIAbi.Fd, - flags: WASIAbi.LookupFlags, - path: String - ) throws -> WASIAbi.Filestat - - /// Adjust the timestamps of a file or directory. - func path_filestat_set_times( - dirFd: WASIAbi.Fd, - flags: WASIAbi.LookupFlags, - path: String, - atim: WASIAbi.Timestamp, - mtim: WASIAbi.Timestamp, - fstFlags: WASIAbi.FstFlags - ) throws - - /// Create a hard link. - func path_link( - oldFd: WASIAbi.Fd, oldFlags: WASIAbi.LookupFlags, oldPath: String, - newFd: WASIAbi.Fd, newPath: String - ) throws - - /// Open a file or directory. - func path_open( - dirFd: WASIAbi.Fd, - dirFlags: WASIAbi.LookupFlags, - path: String, - oflags: WASIAbi.Oflags, - fsRightsBase: WASIAbi.Rights, - fsRightsInheriting: WASIAbi.Rights, - fdflags: WASIAbi.Fdflags - ) throws -> WASIAbi.Fd - - /// Read the contents of a symbolic link. - func path_readlink( - fd: WASIAbi.Fd, path: String, - buffer: UnsafeGuestBufferPointer - ) throws -> WASIAbi.Size - - /// Remove a directory. - func path_remove_directory(dirFd: WASIAbi.Fd, path: String) throws - - /// Rename a file or directory. - func path_rename( - oldFd: WASIAbi.Fd, oldPath: String, - newFd: WASIAbi.Fd, newPath: String - ) throws - - /// Create a symbolic link. - func path_symlink( - oldPath: String, dirFd: WASIAbi.Fd, newPath: String - ) throws - - /// Unlink a file. - func path_unlink_file( - dirFd: WASIAbi.Fd, - path: String - ) throws - - /// Concurrently poll for the occurrence of a set of events. - func poll_oneoff( - subscriptions: UnsafeGuestRawPointer, - events: UnsafeGuestRawPointer, - numberOfSubscriptions: WASIAbi.Size - ) throws -> WASIAbi.Size - - /// Write high-quality random data into a buffer. - func random_get(buffer: UnsafeGuestPointer, length: WASIAbi.Size) -} - enum WASIAbi { enum Errno: UInt32, Error { /// No error occurred. System call completed successfully. @@ -816,7 +612,7 @@ public struct WASIHostModule { public let functions: [String: WASIHostFunction] } -extension WASI { +extension WASIImplementation { var _hostModules: [String: WASIHostModule] { let unimplementedFunctionTypes: [String: FunctionType] = [ "poll_oneoff": .init(parameters: [.i32, .i32, .i32, .i32], results: [.i32]), @@ -1366,58 +1162,37 @@ extension WASI { } } -public class WASIBridgeToHost: WASI { +final class WASIImplementation { private let args: [String] private let environment: [String: String] - private var fdTable: FdTable private let wallClock: WallClock private let monotonicClock: MonotonicClock private var randomGenerator: RandomBufferGenerator + internal var fdTable: FdTable + internal let fileSystem: FileSystemImplementation - public init( + init( args: [String] = [], environment: [String: String] = [:], - preopens: [String: String] = [:], - stdin: FileDescriptor = .standardInput, - stdout: FileDescriptor = .standardOutput, - stderr: FileDescriptor = .standardError, - wallClock: WallClock = SystemWallClock(), - monotonicClock: MonotonicClock = SystemMonotonicClock(), - randomGenerator: RandomBufferGenerator = SystemRandomNumberGenerator() + fileSystem: FileSystemImplementation, + wallClock: WallClock, + monotonicClock: MonotonicClock, + randomGenerator: RandomBufferGenerator ) throws { self.args = args self.environment = environment - var fdTable = FdTable() - fdTable[0] = .file(StdioFileEntry(fd: stdin, accessMode: .read)) - fdTable[1] = .file(StdioFileEntry(fd: stdout, accessMode: .write)) - fdTable[2] = .file(StdioFileEntry(fd: stderr, accessMode: .write)) - - for (guestPath, hostPath) in preopens { - #if os(Windows) || os(WASI) - let fd = try FileDescriptor.open(FilePath(hostPath), .readWrite) - #else - let fd = try hostPath.withCString { cHostPath in - let fd = open(cHostPath, O_DIRECTORY) - if fd < 0 { - let errno = errno - throw WASIError(description: "Failed to open preopen path '\(hostPath)': \(String(cString: strerror(errno)))") - } - return FileDescriptor(rawValue: fd) - } - #endif + self.fileSystem = fileSystem - if try fd.attributes().fileType.isDirectory { - _ = try fdTable.push(.directory(DirEntry(preopenPath: guestPath, fd: fd))) - } - } - self.fdTable = fdTable + self.fdTable = FdTable() self.wallClock = wallClock self.monotonicClock = monotonicClock self.randomGenerator = randomGenerator } - public var wasiHostModules: [String: WASIHostModule] { _hostModules } - + /// Reads command-line argument data. + /// - Parameters: + /// - argv: Pointer to an array of argument strings to be written + /// - argvBuffer: Pointer to a buffer of argument strings to be written func args_get( argv: UnsafeGuestPointer>, argvBuffer: UnsafeGuestPointer @@ -1438,6 +1213,8 @@ public class WASIBridgeToHost: WASI { } } + /// Return command-line argument data sizes. + /// - Returns: Tuple of number of arguments and required buffer size func args_sizes_get() -> (WASIAbi.Size, WASIAbi.Size) { let bufferSize = args.reduce(0) { // `utf8CString` returns null-terminated bytes and WASI also expect it @@ -1446,6 +1223,7 @@ public class WASIBridgeToHost: WASI { return (WASIAbi.Size(args.count), WASIAbi.Size(bufferSize)) } + /// Read environment variable data. func environ_get(environ: UnsafeGuestPointer>, environBuffer: UnsafeGuestPointer) { var offsets = environ var buffer = environBuffer @@ -1463,6 +1241,8 @@ public class WASIBridgeToHost: WASI { } } + /// Return environment variable data sizes. + /// - Returns: Tuple of number of environment variables and required buffer size func environ_sizes_get() -> (WASIAbi.Size, WASIAbi.Size) { let bufferSize = environment.reduce(0) { // `utf8CString` returns null-terminated bytes and WASI also expect it @@ -1471,6 +1251,7 @@ public class WASIBridgeToHost: WASI { return (WASIAbi.Size(environment.count), WASIAbi.Size(bufferSize)) } + /// Return the resolution of a clock. func clock_res_get(id: WASIAbi.ClockId) throws -> WASIAbi.Timestamp { switch id { case .REALTIME: @@ -1482,6 +1263,7 @@ public class WASIBridgeToHost: WASI { } } + /// Return the time value of a clock. func clock_time_get( id: WASIAbi.ClockId, precision: WASIAbi.Timestamp ) throws -> WASIAbi.Timestamp { @@ -1495,6 +1277,7 @@ public class WASIBridgeToHost: WASI { } } + /// Provide file advisory information on a file descriptor. func fd_advise(fd: WASIAbi.Fd, offset: WASIAbi.FileSize, length: WASIAbi.FileSize, advice: WASIAbi.Advice) throws { guard case .file(let fileEntry) = fdTable[fd] else { throw WASIAbi.Errno.EBADF @@ -1502,6 +1285,7 @@ public class WASIBridgeToHost: WASI { try fileEntry.advise(offset: offset, length: length, advice: advice) } + /// Force the allocation of space in a file. func fd_allocate(fd: WASIAbi.Fd, offset: WASIAbi.FileSize, length: WASIAbi.FileSize) throws { guard fdTable[fd] != nil else { throw WASIAbi.Errno.EBADF @@ -1511,6 +1295,7 @@ public class WASIBridgeToHost: WASI { throw WASIAbi.Errno.ENOTSUP } + /// Close a file descriptor. func fd_close(fd: WASIAbi.Fd) throws { guard let entry = fdTable[fd] else { throw WASIAbi.Errno.EBADF @@ -1519,6 +1304,7 @@ public class WASIBridgeToHost: WASI { try entry.asEntry().close() } + /// Synchronize the data of a file to disk. func fd_datasync(fd: WASIAbi.Fd) throws { guard case .file(let fileEntry) = fdTable[fd] else { throw WASIAbi.Errno.EBADF @@ -1526,6 +1312,8 @@ public class WASIBridgeToHost: WASI { return try fileEntry.datasync() } + /// Get the attributes of a file descriptor. + /// - Parameter fileDescriptor: File descriptor to get attribute. func fd_fdstat_get(fileDescriptor: UInt32) throws -> WASIAbi.FdStat { let entry = self.fdTable[fileDescriptor] switch entry { @@ -1543,6 +1331,7 @@ public class WASIBridgeToHost: WASI { } } + /// Adjust the flags associated with a file descriptor. func fd_fdstat_set_flags(fd: WASIAbi.Fd, flags: WASIAbi.Fdflags) throws { guard case .file(let fileEntry) = fdTable[fd] else { throw WASIAbi.Errno.EBADF @@ -1550,6 +1339,7 @@ public class WASIBridgeToHost: WASI { try fileEntry.setFdStatFlags(flags) } + /// Adjust the rights associated with a file descriptor. func fd_fdstat_set_rights( fd: WASIAbi.Fd, fsRightsBase: WASIAbi.Rights, @@ -1558,6 +1348,7 @@ public class WASIBridgeToHost: WASI { throw WASIAbi.Errno.ENOTSUP } + /// Return the attributes of an open file. func fd_filestat_get(fd: WASIAbi.Fd) throws -> WASIAbi.Filestat { guard let entry = fdTable[fd] else { throw WASIAbi.Errno.EBADF @@ -1565,6 +1356,7 @@ public class WASIBridgeToHost: WASI { return try entry.asEntry().attributes() } + /// Adjust the size of an open file. If this increases the file's size, the extra bytes are filled with zeros. func fd_filestat_set_size(fd: WASIAbi.Fd, size: WASIAbi.FileSize) throws { guard case .file(let entry) = fdTable[fd] else { throw WASIAbi.Errno.EBADF @@ -1572,6 +1364,7 @@ public class WASIBridgeToHost: WASI { return try entry.setFilestatSize(size) } + /// Adjust the timestamps of an open file or directory. func fd_filestat_set_times( fd: WASIAbi.Fd, atim: WASIAbi.Timestamp, mtim: WASIAbi.Timestamp, fstFlags: WASIAbi.FstFlags @@ -1582,6 +1375,7 @@ public class WASIBridgeToHost: WASI { try entry.asEntry().setTimes(atim: atim, mtim: mtim, fstFlags: fstFlags) } + /// Read from a file descriptor, without using and updating the file descriptor's offset. func fd_pread( fd: WASIAbi.Fd, iovs: UnsafeGuestBufferPointer, offset: WASIAbi.FileSize @@ -1592,6 +1386,7 @@ public class WASIBridgeToHost: WASI { return try fileEntry.pread(into: iovs, offset: offset) } + /// Return a description of the given preopened file descriptor. func fd_prestat_get(fd: WASIAbi.Fd) throws -> WASIAbi.Prestat { guard case .directory(let entry) = fdTable[fd], let preopenPath = entry.preopenPath @@ -1601,6 +1396,7 @@ public class WASIBridgeToHost: WASI { return .dir(WASIAbi.PrestatDir(preopenPath.utf8.count)) } + /// Return a directory name of the given preopened file descriptor func fd_prestat_dir_name(fd: WASIAbi.Fd, path: UnsafeGuestPointer, maxPathLength: WASIAbi.Size) throws { guard case .directory(let entry) = fdTable[fd], var preopenPath = entry.preopenPath @@ -1618,6 +1414,7 @@ public class WASIBridgeToHost: WASI { } } + /// Write to a file descriptor, without using and updating the file descriptor's offset. func fd_pwrite( fd: WASIAbi.Fd, iovs: UnsafeGuestBufferPointer, offset: WASIAbi.FileSize @@ -1628,6 +1425,7 @@ public class WASIBridgeToHost: WASI { return try fileEntry.pwrite(vectored: iovs, offset: offset) } + /// Read from a file descriptor. func fd_read( fd: WASIAbi.Fd, iovs: UnsafeGuestBufferPointer @@ -1638,6 +1436,7 @@ public class WASIBridgeToHost: WASI { return try fileEntry.read(into: iovs) } + /// Read directory entries from a directory. func fd_readdir( fd: WASIAbi.Fd, buffer: UnsafeGuestBufferPointer, @@ -1690,10 +1489,12 @@ public class WASIBridgeToHost: WASI { return bufferUsed } + /// Atomically replace a file descriptor by renumbering another file descriptor. func fd_renumber(fd: WASIAbi.Fd, to toFd: WASIAbi.Fd) throws { throw WASIAbi.Errno.ENOTSUP } + /// Move the offset of a file descriptor. func fd_seek(fd: WASIAbi.Fd, offset: WASIAbi.FileDelta, whence: WASIAbi.Whence) throws -> WASIAbi.FileSize { guard case .file(let fileEntry) = fdTable[fd] else { throw WASIAbi.Errno.EBADF @@ -1701,6 +1502,7 @@ public class WASIBridgeToHost: WASI { return try fileEntry.seek(offset: offset, whence: whence) } + /// Synchronize the data and metadata of a file to disk. func fd_sync(fd: WASIAbi.Fd) throws { guard case .file(let fileEntry) = fdTable[fd] else { throw WASIAbi.Errno.EBADF @@ -1708,6 +1510,7 @@ public class WASIBridgeToHost: WASI { return try fileEntry.sync() } + /// Return the current offset of a file descriptor. func fd_tell(fd: WASIAbi.Fd) throws -> WASIAbi.FileSize { guard case .file(let fileEntry) = fdTable[fd] else { throw WASIAbi.Errno.EBADF @@ -1715,6 +1518,11 @@ public class WASIBridgeToHost: WASI { return try fileEntry.tell() } + /// POSIX `writev` equivalent. + /// - Parameters: + /// - fileDescriptor: File descriptor to write to. + /// - ioVectors: Buffer pointer to an array of byte buffers to write. + /// - Returns: Number of bytes written. func fd_write( fileDescriptor: WASIAbi.Fd, ioVectors: UnsafeGuestBufferPointer @@ -1725,6 +1533,7 @@ public class WASIBridgeToHost: WASI { return try entry.write(vectored: ioVectors) } + /// Create a directory. func path_create_directory(dirFd: WASIAbi.Fd, path: String) throws { guard case .directory(let dirEntry) = fdTable[dirFd] else { throw WASIAbi.Errno.ENOTDIR @@ -1732,6 +1541,7 @@ public class WASIBridgeToHost: WASI { try dirEntry.createDirectory(atPath: path) } + /// Return the attributes of a file or directory. func path_filestat_get( dirFd: WASIAbi.Fd, flags: WASIAbi.LookupFlags, path: String ) throws -> WASIAbi.Filestat { @@ -1743,6 +1553,7 @@ public class WASIBridgeToHost: WASI { ) } + /// Adjust the timestamps of a file or directory. func path_filestat_set_times( dirFd: WASIAbi.Fd, flags: WASIAbi.LookupFlags, path: String, atim: WASIAbi.Timestamp, mtim: WASIAbi.Timestamp, @@ -1758,6 +1569,7 @@ public class WASIBridgeToHost: WASI { ) } + /// Create a hard link. func path_link( oldFd: WASIAbi.Fd, oldFlags: WASIAbi.LookupFlags, oldPath: String, newFd: WASIAbi.Fd, newPath: String @@ -1765,6 +1577,7 @@ public class WASIBridgeToHost: WASI { throw WASIAbi.Errno.ENOTSUP } + /// Open a file or directory. func path_open( dirFd: WASIAbi.Fd, dirFlags: WASIAbi.LookupFlags, @@ -1774,47 +1587,30 @@ public class WASIBridgeToHost: WASI { fsRightsInheriting: WASIAbi.Rights, fdflags: WASIAbi.Fdflags ) throws -> WASIAbi.Fd { - #if os(Windows) - throw WASIAbi.Errno.ENOTSUP - #else - guard case .directory(let dirEntry) = fdTable[dirFd] else { - throw WASIAbi.Errno.ENOTDIR - } - var accessMode: FileAccessMode = [] - if fsRightsBase.contains(.FD_READ) { - accessMode.insert(.read) - } - if fsRightsBase.contains(.FD_WRITE) { - accessMode.insert(.write) - } - let hostFd = try dirEntry.openFile( - symlinkFollow: dirFlags.contains(.SYMLINK_FOLLOW), - path: path, oflags: oflags, accessMode: accessMode, - fdflags: fdflags - ) + guard case .directory(let dirEntry) = fdTable[dirFd] else { + throw WASIAbi.Errno.ENOTDIR + } - let actualFileType = try hostFd.attributes().fileType - if oflags.contains(.DIRECTORY), actualFileType != .directory { - // Check O_DIRECTORY validity just in case when the host system - // doesn't respects O_DIRECTORY. - throw WASIAbi.Errno.ENOTDIR - } + let newEntry = try fileSystem.openAt( + dirFd: dirEntry, + path: path, + oflags: oflags, + fsRightsBase: fsRightsBase, + fsRightsInheriting: fsRightsInheriting, + fdflags: fdflags, + symlinkFollow: dirFlags.contains(.SYMLINK_FOLLOW) + ) - let newEntry: FdEntry - if actualFileType == .directory { - newEntry = .directory(DirEntry(preopenPath: nil, fd: hostFd)) - } else { - newEntry = .file(RegularFileEntry(fd: hostFd, accessMode: accessMode)) - } - let guestFd = try fdTable.push(newEntry) - return guestFd - #endif + let guestFd = try fdTable.push(newEntry) + return guestFd } + /// Read the contents of a symbolic link. func path_readlink(fd: WASIAbi.Fd, path: String, buffer: UnsafeGuestBufferPointer) throws -> WASIAbi.Size { throw WASIAbi.Errno.ENOTSUP } + /// Remove a directory. func path_remove_directory(dirFd: WASIAbi.Fd, path: String) throws { guard case .directory(let dirEntry) = fdTable[dirFd] else { throw WASIAbi.Errno.ENOTDIR @@ -1822,6 +1618,7 @@ public class WASIBridgeToHost: WASI { try dirEntry.removeDirectory(atPath: path) } + /// Rename a file or directory. func path_rename( oldFd: WASIAbi.Fd, oldPath: String, newFd: WASIAbi.Fd, newPath: String @@ -1835,6 +1632,7 @@ public class WASIBridgeToHost: WASI { try oldDirEntry.rename(from: oldPath, toDir: newDirEntry, to: newPath) } + /// Create a symbolic link. func path_symlink(oldPath: String, dirFd: WASIAbi.Fd, newPath: String) throws { guard case .directory(let dirEntry) = fdTable[dirFd] else { throw WASIAbi.Errno.ENOTDIR @@ -1842,6 +1640,7 @@ public class WASIBridgeToHost: WASI { try dirEntry.symlink(from: oldPath, to: newPath) } + /// Unlink a file. func path_unlink_file(dirFd: WASIAbi.Fd, path: String) throws { guard case .directory(let dirEntry) = fdTable[dirFd] else { throw WASIAbi.Errno.ENOTDIR @@ -1849,6 +1648,7 @@ public class WASIBridgeToHost: WASI { try dirEntry.removeFile(atPath: path) } + /// Concurrently poll for the occurrence of a set of events. func poll_oneoff( subscriptions: UnsafeGuestRawPointer, events: UnsafeGuestRawPointer, @@ -1857,6 +1657,7 @@ public class WASIBridgeToHost: WASI { throw WASIAbi.Errno.ENOTSUP } + /// Write high-quality random data into a buffer. func random_get(buffer: UnsafeGuestPointer, length: WASIAbi.Size) { guard length > 0 else { return } buffer.withHostPointer(count: Int(length)) { diff --git a/Sources/WASI/WASIBridgeToHost.swift b/Sources/WASI/WASIBridgeToHost.swift new file mode 100644 index 00000000..6eb4fbbc --- /dev/null +++ b/Sources/WASI/WASIBridgeToHost.swift @@ -0,0 +1,164 @@ +import SystemPackage + +/// A bridge that connects WebAssembly System Interface (WASI) calls to the host system. +/// +/// `WASIBridgeToHost` provides a high-level interface for configuring and executing +/// WASI-compliant WebAssembly modules. It handles file system access, standard I/O, +/// command-line arguments, environment variables, and system resources like clocks +/// and random number generation. +/// +/// ## Usage Example +/// ```swift +/// let bridge = try WASIBridgeToHost( +/// args: ["program", "--flag"], +/// environment: ["PATH": "/usr/bin"], +/// preopens: ["/sandbox": "/real/path"] +/// ) +/// ``` +public final class WASIBridgeToHost { + internal let underlying: WASIImplementation + + /// Configuration options for the file system implementation used by WASI. + /// + /// This structure allows you to choose between different file system backends + /// (host file system or in-memory file system) and configure standard I/O streams. + public struct FileSystemOptions { + internal let factory: () throws -> FileSystemImplementation + internal var initializeStdio: ((inout FdTable) throws -> Void)? + internal var initializePreopens: ((FileSystemImplementation, inout FdTable) throws -> Void)? + + /// Creates file system options that use the host operating system's file system. + /// + /// - Returns: A configured `FileSystemOptions` instance using the host file system. + public static func host() -> FileSystemOptions { + return FileSystemOptions(factory: { HostFileSystem() }) + } + + /// Creates file system options that use an in-memory file system. + /// + /// - Parameter fileSystem: A pre-configured `MemoryFileSystem` instance. + /// - Returns: A configured `FileSystemOptions` instance using the memory file system. + public static func memory(_ fileSystem: MemoryFileSystem) -> FileSystemOptions { + return FileSystemOptions(factory: { fileSystem }) + } + + /// Configures the file system options with custom standard I/O streams. + /// + /// This method allows you to redirect stdin, stdout, and stderr to different + /// file descriptors than the system defaults. + /// + /// - Parameters: + /// - stdin: The file descriptor to use for standard input. Defaults to `.standardInput`. + /// - stdout: The file descriptor to use for standard output. Defaults to `.standardOutput`. + /// - stderr: The file descriptor to use for standard error. Defaults to `.standardError`. + /// - Returns: A new `FileSystemOptions` instance with the configured standard I/O streams. + public func withStdio( + stdin: FileDescriptor = .standardInput, + stdout: FileDescriptor = .standardOutput, + stderr: FileDescriptor = .standardError + ) -> FileSystemOptions { + var options = self + options.initializeStdio = { fdTable in + fdTable[0] = .file(StdioFileEntry(fd: stdin, accessMode: .read)) + fdTable[1] = .file(StdioFileEntry(fd: stdout, accessMode: .write)) + fdTable[2] = .file(StdioFileEntry(fd: stderr, accessMode: .write)) + } + return options + } + + /// Configures the file system options with preopens. + /// + /// - Parameter preopens: A dictionary mapping guest paths to host paths. The keys are + /// paths as seen by the WASI module, and the values are actual host file system paths. + /// These directories will be pre-opened and made accessible to the WebAssembly module. + /// - Returns: A new `FileSystemOptions` instance with the configured preopens. + public func withPreopens(_ preopens: [String: String]) -> FileSystemOptions { + var options = self + options.initializePreopens = { fileSystem, fdTable in + for (guestPath, hostPath) in preopens { + let dirEntry = try fileSystem.preopenDirectory(guestPath: guestPath, hostPath: hostPath) + _ = try fdTable.push(.directory(dirEntry)) + } + } + return options + } + } + + /// The WASI host modules that implement the WASI system calls. + /// + /// This property provides access to the underlying host module implementations, + /// which can be used to register with a WebAssembly runtime. + public var wasiHostModules: [String: WASIHostModule] { underlying._hostModules } + + /// Creates a new WASI bridge with host file system access. + /// + /// This is a convenience initializer that automatically configures the bridge + /// to use the host operating system's file system with the specified preopens + /// and standard I/O descriptors. + /// + /// - Parameters: + /// - args: Command-line arguments to pass to the WASI module. Defaults to an empty array. + /// - environment: Environment variables to expose to the WASI module. Defaults to an empty dictionary. + /// - preopens: Pre-opened directories mapping guest paths to host paths. Defaults to an empty dictionary. + /// - stdin: File descriptor for standard input. Defaults to `.standardInput`. + /// - stdout: File descriptor for standard output. Defaults to `.standardOutput`. + /// - stderr: File descriptor for standard error. Defaults to `.standardError`. + /// - wallClock: Clock for wall-clock time queries. Defaults to `SystemWallClock()`. + /// - monotonicClock: Clock for monotonic time queries. Defaults to `SystemMonotonicClock()`. + /// - randomGenerator: Random number generator. Defaults to `SystemRandomNumberGenerator()`. + /// - Throws: An error if the file system or preopens cannot be initialized. + public convenience init( + args: [String] = [], + environment: [String: String] = [:], + preopens: [String: String] = [:], + stdin: FileDescriptor = .standardInput, + stdout: FileDescriptor = .standardOutput, + stderr: FileDescriptor = .standardError, + wallClock: WallClock = SystemWallClock(), + monotonicClock: MonotonicClock = SystemMonotonicClock(), + randomGenerator: RandomBufferGenerator = SystemRandomNumberGenerator() + ) throws { + try self.init( + args: args, + environment: environment, + fileSystem: .host().withStdio(stdin: stdin, stdout: stdout, stderr: stderr).withPreopens(preopens), + wallClock: wallClock, + monotonicClock: monotonicClock, + randomGenerator: randomGenerator + ) + } + + /// Creates a new WASI bridge with custom file system options. + /// + /// This is the designated initializer that allows full control over the file system + /// backend (host or in-memory) and all other WASI subsystems. + /// + /// - Parameters: + /// - args: Command-line arguments to pass to the WASI module. Defaults to an empty array. + /// - environment: Environment variables to expose to the WASI module. Defaults to an empty dictionary. + /// - fileSystem: Configuration for the file system implementation. Defaults to `.host()`. + /// - wallClock: Clock for wall-clock time queries. Defaults to `SystemWallClock()`. + /// - monotonicClock: Clock for monotonic time queries. Defaults to `SystemMonotonicClock()`. + /// - randomGenerator: Random number generator. Defaults to `SystemRandomNumberGenerator()`. + /// - Throws: An error if the file system or initialization fails. + public init( + args: [String] = [], + environment: [String: String] = [:], + fileSystem fileSystemOptions: FileSystemOptions = .host(), + wallClock: WallClock = SystemWallClock(), + monotonicClock: MonotonicClock = SystemMonotonicClock(), + randomGenerator: RandomBufferGenerator = SystemRandomNumberGenerator() + ) throws { + let fileSystem = try fileSystemOptions.factory() + self.underlying = try WASIImplementation( + args: args, + environment: environment, + fileSystem: fileSystem, + wallClock: wallClock, + monotonicClock: monotonicClock, + randomGenerator: randomGenerator + ) + try fileSystemOptions.initializeStdio?(&underlying.fdTable) + try fileSystemOptions.initializePreopens?(fileSystem, &underlying.fdTable) + } +} diff --git a/Sources/WasmKitWASI/WASIBridgeToHost+WasmKit.swift b/Sources/WasmKitWASI/WASIBridgeToHost+WasmKit.swift index 23e364ee..e02d3d90 100644 --- a/Sources/WasmKitWASI/WASIBridgeToHost+WasmKit.swift +++ b/Sources/WasmKitWASI/WASIBridgeToHost+WasmKit.swift @@ -2,6 +2,7 @@ import WASI import WasmKit public typealias WASIBridgeToHost = WASI.WASIBridgeToHost +public typealias MemoryFileSystem = WASI.MemoryFileSystem extension WASIBridgeToHost { diff --git a/Tests/WASITests/TestSupport.swift b/Tests/WASITests/TestSupport.swift index 2dd1a01f..d0fcda65 100644 --- a/Tests/WASITests/TestSupport.swift +++ b/Tests/WASITests/TestSupport.swift @@ -1,5 +1,11 @@ import Foundation +@testable import WASI +@testable import WasmKit + +#if canImport(System) + import SystemPackage +#endif enum TestSupport { struct Error: Swift.Error, CustomStringConvertible { let description: String @@ -13,6 +19,106 @@ enum TestSupport { } } + class TestGuestMemory: GuestMemory { + private var data: [UInt8] + + init(size: Int = 65536) { + self.data = Array(repeating: 0, count: size) + } + + func withUnsafeMutableBufferPointer( + offset: UInt, + count: Int, + _ body: (UnsafeMutableRawBufferPointer) throws -> T + ) rethrows -> T { + guard offset + UInt(count) <= data.count else { + fatalError("Memory access out of bounds") + } + return try data.withUnsafeMutableBytes { buffer in + let start = buffer.baseAddress!.advanced(by: Int(offset)) + let slice = UnsafeMutableRawBufferPointer(start: start, count: count) + return try body(slice) + } + } + + func write(_ bytes: [UInt8], at offset: UInt) { + data.replaceSubrange(Int(offset).. UnsafeGuestBufferPointer { + var currentDataOffset: UInt32 = 0 + let iovecOffset: UInt32 = 32768 + + for buffer in buffers { + write(buffer, at: UInt(currentDataOffset)) + currentDataOffset += UInt32(buffer.count) + } + + var iovecWriteOffset = iovecOffset + var dataReadOffset: UInt32 = 0 + for buffer in buffers { + let iovec = WASIAbi.IOVec( + buffer: UnsafeGuestRawPointer(memorySpace: self, offset: dataReadOffset), + length: UInt32(buffer.count) + ) + WASIAbi.IOVec.writeToGuest( + at: UnsafeGuestRawPointer(memorySpace: self, offset: iovecWriteOffset), + value: iovec + ) + dataReadOffset += UInt32(buffer.count) + iovecWriteOffset += WASIAbi.IOVec.sizeInGuest + } + + return UnsafeGuestBufferPointer( + baseAddress: UnsafeGuestPointer(memorySpace: self, offset: iovecOffset), + count: UInt32(buffers.count) + ) + } + + func readIOVecs(sizes: [Int]) -> UnsafeGuestBufferPointer { + var currentDataOffset: UInt32 = 0 + let iovecOffset: UInt32 = 32768 + + var iovecWriteOffset = iovecOffset + for size in sizes { + let iovec = WASIAbi.IOVec( + buffer: UnsafeGuestRawPointer(memorySpace: self, offset: currentDataOffset), + length: UInt32(size) + ) + WASIAbi.IOVec.writeToGuest( + at: UnsafeGuestRawPointer(memorySpace: self, offset: iovecWriteOffset), + value: iovec + ) + currentDataOffset += UInt32(size) + iovecWriteOffset += WASIAbi.IOVec.sizeInGuest + } + + return UnsafeGuestBufferPointer( + baseAddress: UnsafeGuestPointer(memorySpace: self, offset: iovecOffset), + count: UInt32(sizes.count) + ) + } + + func loadIOVecs(_ iovecs: UnsafeGuestBufferPointer) -> [[UInt8]] { + var result: [[UInt8]] = [] + + for i in 0.. FileDescriptor { + let fileURL = url.appendingPathComponent(relativePath) + return try FileDescriptor.open(fileURL.path, mode) + } + #endif + deinit { _ = try? FileManager.default.removeItem(atPath: path) } diff --git a/Tests/WASITests/WASITests.swift b/Tests/WASITests/WASITests.swift index 2cd5ef94..8a334049 100644 --- a/Tests/WASITests/WASITests.swift +++ b/Tests/WASITests/WASITests.swift @@ -26,12 +26,12 @@ struct WASITests { try t.createSymlink(at: "Sandbox/link-loop.txt", to: "link-loop.txt") let wasi = try WASIBridgeToHost( - preopens: ["/Sandbox": t.url.appendingPathComponent("Sandbox").path] + fileSystem: .host().withPreopens(["/Sandbox": t.url.appendingPathComponent("Sandbox").path]) ) let mntFd: WASIAbi.Fd = 3 func assertResolve(_ path: String, followSymlink: Bool, directory: Bool = false) throws { - let fd = try wasi.path_open( + let fd = try wasi.underlying.path_open( dirFd: mntFd, dirFlags: followSymlink ? [.SYMLINK_FOLLOW] : [], path: path, @@ -40,7 +40,7 @@ struct WASITests { fsRightsInheriting: .DIRECTORY_INHERITING_RIGHTS, fdflags: [] ) - try wasi.fd_close(fd: fd) + try wasi.underlying.fd_close(fd: fd) } func assertNotResolve( @@ -51,7 +51,7 @@ struct WASITests { _ checkError: ((WASIAbi.Errno) throws -> Void)? ) throws { do { - _ = try wasi.path_open( + _ = try wasi.underlying.path_open( dirFd: mntFd, dirFlags: followSymlink ? [.SYMLINK_FOLLOW] : [], path: path, @@ -134,4 +134,1035 @@ struct WASITests { } } #endif + + @Test + func memoryFileSystem() throws { + let fs = try MemoryFileSystem() + _ = try fs.ensureDirectory(at: "/") + + try fs.addFile(at: "/hello.txt", content: Array("Hello, World!".utf8)) + let node = fs.lookup(at: "/hello.txt") + #expect(node != nil) + #expect(node?.type == .file) + + guard let fileNode = node as? MemoryFileNode else { + #expect(Bool(false), "Expected FileNode") + return + } + + guard case .bytes(let content) = fileNode.content else { + #expect(Bool(false), "Expected bytes content") + return + } + + #expect(content == Array("Hello, World!".utf8)) + #expect(fileNode.size == 13) + + try fs.ensureDirectory(at: "/dir/subdir") + #expect(fs.lookup(at: "/dir") != nil) + #expect(fs.lookup(at: "/dir/subdir") != nil) + + try fs.addFile(at: "/dir/file.txt", content: Array("test".utf8)) + #expect(fs.lookup(at: "/dir/file.txt") != nil) + + try fs.removeFile(at: "/dir/file.txt") + #expect(fs.lookup(at: "/dir/file.txt") == nil) + } + + @Test + func memoryFileSystemBridge() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + try fs.addFile(at: "/test.txt", content: Array("Test Content".utf8)) + try fs.ensureDirectory(at: "/testdir") + + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withPreopens(["/": "/"])).underlying + + let rootFd: WASIAbi.Fd = 3 + + let fd = try wasi.path_open( + dirFd: rootFd, + dirFlags: [], + path: "test.txt", + oflags: [], + fsRightsBase: [.FD_READ], + fsRightsInheriting: [], + fdflags: [] + ) + + let stat = try wasi.fd_filestat_get(fd: fd) + #expect(stat.filetype == .REGULAR_FILE) + #expect(stat.size == 12) + + try wasi.fd_close(fd: fd) + } + + @Test + func memoryFileSystemReadWrite() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + try fs.addFile(at: "/readwrite.txt", content: Array("Initial".utf8)) + + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withPreopens(["/": "/"])).underlying + let rootFd: WASIAbi.Fd = 3 + + let fd = try wasi.path_open( + dirFd: rootFd, + dirFlags: [], + path: "readwrite.txt", + oflags: [], + fsRightsBase: [.FD_READ, .FD_WRITE, .FD_SEEK], + fsRightsInheriting: [], + fdflags: [] + ) + + let newOffset = try wasi.fd_seek(fd: fd, offset: 0, whence: .END) + #expect(newOffset == 7) + + let tell = try wasi.fd_tell(fd: fd) + #expect(tell == 7) + + try wasi.fd_close(fd: fd) + } + + @Test + func memoryFileSystemDirectories() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withPreopens(["/": "/"])).underlying + let rootFd: WASIAbi.Fd = 3 + + try wasi.path_create_directory(dirFd: rootFd, path: "newdir") + #expect(fs.lookup(at: "/newdir") != nil) + + let dirStat = try wasi.path_filestat_get(dirFd: rootFd, flags: [], path: "newdir") + #expect(dirStat.filetype == .DIRECTORY) + + let dirFd = try wasi.path_open( + dirFd: rootFd, + dirFlags: [], + path: "newdir", + oflags: [.DIRECTORY], + fsRightsBase: .DIRECTORY_BASE_RIGHTS, + fsRightsInheriting: .DIRECTORY_INHERITING_RIGHTS, + fdflags: [] + ) + + try fs.addFile(at: "/newdir/file1.txt", content: Array("file1".utf8)) + try fs.addFile(at: "/newdir/file2.txt", content: Array("file2".utf8)) + + try wasi.fd_close(fd: dirFd) + + try wasi.path_unlink_file(dirFd: rootFd, path: "newdir/file1.txt") + #expect(fs.lookup(at: "/newdir/file1.txt") == nil) + #expect(fs.lookup(at: "/newdir/file2.txt") != nil) + } + + @Test + func memoryFileSystemCreateAndTruncate() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withPreopens(["/": "/"])).underlying + let rootFd: WASIAbi.Fd = 3 + + let fd1 = try wasi.path_open( + dirFd: rootFd, + dirFlags: [], + path: "created.txt", + oflags: [.CREAT], + fsRightsBase: [.FD_WRITE], + fsRightsInheriting: [], + fdflags: [] + ) + try wasi.fd_close(fd: fd1) + + #expect(fs.lookup(at: "/created.txt") != nil) + + try fs.addFile(at: "/truncate.txt", content: Array("Long content here".utf8)) + + let fd2 = try wasi.path_open( + dirFd: rootFd, + dirFlags: [], + path: "truncate.txt", + oflags: [.TRUNC], + fsRightsBase: [.FD_WRITE], + fsRightsInheriting: [], + fdflags: [] + ) + + let stat = try wasi.fd_filestat_get(fd: fd2) + #expect(stat.size == 0) + + try wasi.fd_close(fd: fd2) + } + + @Test + func memoryFileSystemExclusive() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + try fs.addFile(at: "/existing.txt", content: []) + + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withPreopens(["/": "/"])).underlying + let rootFd: WASIAbi.Fd = 3 + + do { + _ = try wasi.path_open( + dirFd: rootFd, + dirFlags: [], + path: "existing.txt", + oflags: [.CREAT, .EXCL], + fsRightsBase: [.FD_WRITE], + fsRightsInheriting: [], + fdflags: [] + ) + #expect(Bool(false), "Should have thrown EEXIST") + } catch let error as WASIAbi.Errno { + #expect(error == .EEXIST) + } + } + + @Test + func memoryFileSystemMultiplePreopens() throws { + let fs = try MemoryFileSystem() + let preopens = [ + "/": "/", + "/tmp": "/tmp", + "/data": "/data", + ] + for (_, hostPath) in preopens { + try fs.ensureDirectory(at: hostPath) + } + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withPreopens(preopens)) + #expect(fs.lookup(at: "/tmp") != nil) + #expect(fs.lookup(at: "/data") != nil) + _ = wasi + } + + @Test + func memoryFileSystemPrestatOperations() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/sandbox") + + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withPreopens(["/sandbox": "/sandbox"])).underlying + + let prestat = try wasi.fd_prestat_get(fd: 3) + guard case .dir(let pathLen) = prestat else { + #expect(Bool(false), "Expected directory prestat") + return + } + #expect(pathLen == 8) + } + + @Test + func memoryFileSystemPathNormalization() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + + try fs.addFile(at: "/test.txt", content: [1, 2, 3]) + + #expect(fs.lookup(at: "/test.txt") != nil) + #expect(fs.lookup(at: "//test.txt") != nil) + #expect(fs.lookup(at: "/./test.txt") == nil) + + try fs.ensureDirectory(at: "/a/b/c") + #expect(fs.lookup(at: "/a/b/c") != nil) + #expect(fs.lookup(at: "/a/b") != nil) + #expect(fs.lookup(at: "/a") != nil) + } + + @Test + func memoryFileSystemResolution() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + try fs.ensureDirectory(at: "/dir") + try fs.addFile(at: "/dir/file.txt", content: []) + guard let dirNode = fs.lookup(at: "/dir") as? MemoryDirectoryNode else { + #expect(Bool(false), "Expected DirectoryNode") + return + } + let resolved = fs.resolve(from: dirNode, at: "/dir", path: "file.txt") + #expect(resolved != nil) + #expect(resolved?.type == .file) + + let dotResolved = fs.resolve(from: dirNode, at: "/dir", path: ".") + #expect(dotResolved != nil) + + let parentResolved = fs.resolve(from: dirNode, at: "/dir", path: "..") + #expect(parentResolved != nil) + #expect(parentResolved?.type == .directory) + } + + @Test + func memoryFileSystemWithFileDescriptor() throws { + #if canImport(System) && !os(WASI) + let tempDir = try TestSupport.TemporaryDirectory() + try tempDir.createFile(at: "source.txt", contents: "File descriptor content") + + let fd = try tempDir.openFile(at: "source.txt", .readOnly) + defer { + try? fd.close() + } + + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + try fs.addFile(at: "/mounted.txt", handle: fd) + + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withPreopens(["/": "/"])).underlying + let rootFd: WASIAbi.Fd = 3 + + let openedFd = try wasi.path_open( + dirFd: rootFd, + dirFlags: [], + path: "mounted.txt", + oflags: [], + fsRightsBase: [.FD_READ], + fsRightsInheriting: [], + fdflags: [] + ) + + let stat = try wasi.fd_filestat_get(fd: openedFd) + #expect(stat.filetype == .REGULAR_FILE) + #expect(stat.size == 23) + + try wasi.fd_close(fd: openedFd) + #endif + } + + @Test + func unifiedBridgeWithHostFileSystem() throws { + #if !os(Windows) + let tempDir = try TestSupport.TemporaryDirectory() + try tempDir.createFile(at: "host.txt", contents: "Host content") + + // Using default host filesystem + let wasi = try WASIBridgeToHost( + fileSystem: .host().withPreopens(["/sandbox": tempDir.url.path]) + ).underlying + + let sandboxFd: WASIAbi.Fd = 3 + let fd = try wasi.path_open( + dirFd: sandboxFd, + dirFlags: [], + path: "host.txt", + oflags: [], + fsRightsBase: [.FD_READ], + fsRightsInheriting: [], + fdflags: [] + ) + + let stat = try wasi.fd_filestat_get(fd: fd) + #expect(stat.filetype == .REGULAR_FILE) + #expect(stat.size == 12) + + try wasi.fd_close(fd: fd) + #endif + } + + @Test + func unifiedBridgeWithMemoryFileSystem() throws { + let memFS = try MemoryFileSystem() + try memFS.ensureDirectory(at: "/") + try memFS.addFile(at: "/memory.txt", content: "Memory content") + + // Using memory filesystem through unified bridge + let wasi = try WASIBridgeToHost(fileSystem: .memory(memFS).withPreopens(["/": "/"])).underlying + + let rootFd: WASIAbi.Fd = 3 + let fd = try wasi.path_open( + dirFd: rootFd, + dirFlags: [], + path: "memory.txt", + oflags: [], + fsRightsBase: [.FD_READ], + fsRightsInheriting: [], + fdflags: [] + ) + + let stat = try wasi.fd_filestat_get(fd: fd) + #expect(stat.filetype == .REGULAR_FILE) + #expect(stat.size == 14) + + try wasi.fd_close(fd: fd) + } + + @Test + func memoryFileSystemSeekPositions() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + try fs.addFile(at: "/positions.txt", content: Array("0123456789".utf8)) + + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withPreopens(["/": "/"])).underlying + let rootFd: WASIAbi.Fd = 3 + + let fd = try wasi.path_open( + dirFd: rootFd, + dirFlags: [], + path: "positions.txt", + oflags: [], + fsRightsBase: [.FD_READ, .FD_SEEK, .FD_TELL], + fsRightsInheriting: [], + fdflags: [] + ) + + let startPos = try wasi.fd_tell(fd: fd) + #expect(startPos == 0) + + let endPos = try wasi.fd_seek(fd: fd, offset: 0, whence: .END) + #expect(endPos == 10) + + let currentPos = try wasi.fd_tell(fd: fd) + #expect(currentPos == 10) + + let midPos = try wasi.fd_seek(fd: fd, offset: -5, whence: .CUR) + #expect(midPos == 5) + + try wasi.fd_close(fd: fd) + } + + @Test + func memoryFileSystemAccessModeValidation() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + try fs.addFile(at: "/file.txt", content: Array("test".utf8)) + + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withPreopens(["/": "/"])).underlying + let rootFd: WASIAbi.Fd = 3 + + let readOnlyFd = try wasi.path_open( + dirFd: rootFd, + dirFlags: [], + path: "file.txt", + oflags: [], + fsRightsBase: [.FD_READ], + fsRightsInheriting: [], + fdflags: [] + ) + + let stat = try wasi.fd_fdstat_get(fileDescriptor: readOnlyFd) + #expect(stat.fsRightsBase.contains(.FD_READ)) + #expect(!stat.fsRightsBase.contains(.FD_WRITE)) + + try wasi.fd_close(fd: readOnlyFd) + + let writeOnlyFd = try wasi.path_open( + dirFd: rootFd, + dirFlags: [], + path: "file.txt", + oflags: [], + fsRightsBase: [.FD_WRITE], + fsRightsInheriting: [], + fdflags: [] + ) + + let writeStat = try wasi.fd_fdstat_get(fileDescriptor: writeOnlyFd) + #expect(!writeStat.fsRightsBase.contains(.FD_READ)) + #expect(writeStat.fsRightsBase.contains(.FD_WRITE)) + + try wasi.fd_close(fd: writeOnlyFd) + } + + @Test + func memoryFileSystemWithFileDescriptorReadWrite() throws { + #if canImport(System) && !os(WASI) && !os(Windows) + let tempDir = try TestSupport.TemporaryDirectory() + try tempDir.createFile(at: "rw.txt", contents: "Initial") + + let fd = try tempDir.openFile(at: "rw.txt", .readWrite) + defer { + try? fd.close() + } + + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + try fs.addFile(at: "/handle.txt", handle: fd) + + let content = try fs.getFile(at: "/handle.txt") + guard case .handle(let retrievedFd) = content else { + #expect(Bool(false), "Expected handle content") + return + } + + #expect(retrievedFd.rawValue == fd.rawValue) + #endif + } + + @Test + func memoryFileSystemGetFileContent() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + try fs.addFile(at: "/data.bin", content: [1, 2, 3, 4, 5]) + + let content = try fs.getFile(at: "/data.bin") + guard case .bytes(let bytes) = content else { + #expect(Bool(false), "Expected bytes content") + return + } + #expect(bytes == [1, 2, 3, 4, 5]) + + do { + _ = try fs.getFile(at: "/nonexistent.txt") + #expect(Bool(false), "Should throw ENOENT") + } catch let error as WASIAbi.Errno { + #expect(error == .ENOENT) + } + + try fs.ensureDirectory(at: "/somedir") + do { + _ = try fs.getFile(at: "/somedir") + #expect(Bool(false), "Should throw EISDIR") + } catch let error as WASIAbi.Errno { + #expect(error == .EISDIR) + } + } + + @Test + func memoryFileSystemTruncateViaSetSize() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + try fs.addFile(at: "/truncate.txt", content: Array("Long content here".utf8)) + + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withPreopens(["/": "/"])).underlying + let rootFd: WASIAbi.Fd = 3 + + let fd = try wasi.path_open( + dirFd: rootFd, + dirFlags: [], + path: "truncate.txt", + oflags: [], + fsRightsBase: [.FD_READ, .FD_WRITE, .FD_FILESTAT_SET_SIZE], + fsRightsInheriting: [], + fdflags: [] + ) + + try wasi.fd_filestat_set_size(fd: fd, size: 4) + + let stat = try wasi.fd_filestat_get(fd: fd) + #expect(stat.size == 4) + + let content = try fs.getFile(at: "/truncate.txt") + guard case .bytes(let bytes) = content else { + #expect(Bool(false), "Expected bytes content") + return + } + #expect(bytes == Array("Long".utf8)) + + try wasi.fd_close(fd: fd) + } + + @Test + func memoryFileSystemExpandViaSetSize() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + try fs.addFile(at: "/expand.txt", content: Array("Hi".utf8)) + + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withPreopens(["/": "/"])).underlying + let rootFd: WASIAbi.Fd = 3 + + let fd = try wasi.path_open( + dirFd: rootFd, + dirFlags: [], + path: "expand.txt", + oflags: [], + fsRightsBase: [.FD_WRITE, .FD_FILESTAT_SET_SIZE], + fsRightsInheriting: [], + fdflags: [] + ) + + try wasi.fd_filestat_set_size(fd: fd, size: 10) + + let stat = try wasi.fd_filestat_get(fd: fd) + #expect(stat.size == 10) + + let content = try fs.getFile(at: "/expand.txt") + guard case .bytes(let bytes) = content else { + #expect(Bool(false), "Expected bytes content") + return + } + #expect(bytes.count == 10) + #expect(bytes[0] == UInt8(ascii: "H")) + #expect(bytes[1] == UInt8(ascii: "i")) + #expect(bytes[2] == 0) + #expect(bytes[9] == 0) + + try wasi.fd_close(fd: fd) + } + + @Test + func memoryFileSystemRename() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + try fs.addFile(at: "/old.txt", content: Array("Content".utf8)) + + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withPreopens(["/": "/"])).underlying + let rootFd: WASIAbi.Fd = 3 + + try wasi.path_rename( + oldFd: rootFd, + oldPath: "old.txt", + newFd: rootFd, + newPath: "new.txt" + ) + + #expect(fs.lookup(at: "/old.txt") == nil) + #expect(fs.lookup(at: "/new.txt") != nil) + + let content = try fs.getFile(at: "/new.txt") + guard case .bytes(let bytes) = content else { + #expect(Bool(false), "Expected bytes content") + return + } + #expect(bytes == Array("Content".utf8)) + } + + @Test + func memoryFileSystemRenameToSubdirectory() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + try fs.addFile(at: "/file.txt", content: Array("test".utf8)) + try fs.ensureDirectory(at: "/subdir") + + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withPreopens(["/": "/"])).underlying + let rootFd: WASIAbi.Fd = 3 + + try wasi.path_rename( + oldFd: rootFd, + oldPath: "file.txt", + newFd: rootFd, + newPath: "subdir/moved.txt" + ) + + #expect(fs.lookup(at: "/file.txt") == nil) + #expect(fs.lookup(at: "/subdir/moved.txt") != nil) + } + + @Test + func memoryFileSystemRemoveEmptyDirectory() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + try fs.ensureDirectory(at: "/emptydir") + + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withPreopens(["/": "/"])).underlying + let rootFd: WASIAbi.Fd = 3 + + try wasi.path_remove_directory(dirFd: rootFd, path: "emptydir") + #expect(fs.lookup(at: "/emptydir") == nil) + } + + @Test + func memoryFileSystemRemoveNonEmptyDirectoryFails() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + try fs.ensureDirectory(at: "/nonempty") + try fs.addFile(at: "/nonempty/file.txt", content: []) + + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withPreopens(["/": "/"])).underlying + let rootFd: WASIAbi.Fd = 3 + + do { + try wasi.path_remove_directory(dirFd: rootFd, path: "nonempty") + #expect(Bool(false), "Should not remove non-empty directory") + } catch let error as WASIAbi.Errno { + #expect(error == .ENOTEMPTY) + } + + #expect(fs.lookup(at: "/nonempty") != nil) + } + + @Test + func memoryFileSystemSyncOperations() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + try fs.addFile(at: "/sync.txt", content: []) + + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withPreopens(["/": "/"])).underlying + let rootFd: WASIAbi.Fd = 3 + + let fd = try wasi.path_open( + dirFd: rootFd, + dirFlags: [], + path: "sync.txt", + oflags: [], + fsRightsBase: [.FD_SYNC, .FD_DATASYNC], + fsRightsInheriting: [], + fdflags: [] + ) + + try wasi.fd_sync(fd: fd) + try wasi.fd_datasync(fd: fd) + + try wasi.fd_close(fd: fd) + } + + @Test + func memoryFileSystemWriteThenRead() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + try fs.addFile(at: "/test.txt", content: []) + + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withPreopens(["/": "/"])).underlying + let rootFd: WASIAbi.Fd = 3 + + let fd = try wasi.path_open( + dirFd: rootFd, + dirFlags: [], + path: "test.txt", + oflags: [], + fsRightsBase: [.FD_READ, .FD_WRITE], + fsRightsInheriting: [], + fdflags: [] + ) + + let memory = TestSupport.TestGuestMemory() + let writeData = Array("Hello, WASI!".utf8) + let writeVecs = memory.writeIOVecs([writeData]) + + let nwritten = try wasi.fd_write(fileDescriptor: fd, ioVectors: writeVecs) + #expect(nwritten == UInt32(writeData.count)) + + _ = try wasi.fd_seek(fd: fd, offset: 0, whence: .SET) + + let readVecs = memory.readIOVecs(sizes: [writeData.count]) + let nread = try wasi.fd_read(fd: fd, iovs: readVecs) + #expect(nread == UInt32(writeData.count)) + + let readData = memory.loadIOVecs(readVecs) + #expect(readData[0] == writeData) + + try wasi.fd_close(fd: fd) + } + + @Test + func memoryFileSystemReadOnlyAccess() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + try fs.addFile(at: "/readonly.txt", content: Array("Read only".utf8)) + + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withPreopens(["/": "/"])).underlying + let rootFd: WASIAbi.Fd = 3 + + let fd = try wasi.path_open( + dirFd: rootFd, + dirFlags: [], + path: "readonly.txt", + oflags: [], + fsRightsBase: [.FD_READ], + fsRightsInheriting: [], + fdflags: [] + ) + + do { + let memory = TestSupport.TestGuestMemory() + let writeData = Array("Fail".utf8) + let iovecs = memory.writeIOVecs([writeData]) + + _ = try wasi.fd_write(fileDescriptor: fd, ioVectors: iovecs) + #expect(Bool(false), "Should not be able to write to read-only file") + } catch let error as WASIAbi.Errno { + #expect(error == .EBADF) + } + + try wasi.fd_close(fd: fd) + } + + @Test + func memoryFileSystemWriteOnlyAccess() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + try fs.addFile(at: "/writeonly.txt", content: []) + + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withPreopens(["/": "/"])).underlying + let rootFd: WASIAbi.Fd = 3 + + let fd = try wasi.path_open( + dirFd: rootFd, + dirFlags: [], + path: "writeonly.txt", + oflags: [], + fsRightsBase: [.FD_WRITE], + fsRightsInheriting: [], + fdflags: [] + ) + + let memory = TestSupport.TestGuestMemory() + let writeData = Array("Write only".utf8) + let writeVecs = memory.writeIOVecs([writeData]) + + let nwritten = try wasi.fd_write(fileDescriptor: fd, ioVectors: writeVecs) + #expect(nwritten == UInt32(writeData.count)) + + do { + let readVecs = memory.readIOVecs(sizes: [10]) + _ = try wasi.fd_read(fd: fd, iovs: readVecs) + #expect(Bool(false), "Should not be able to read from write-only file") + } catch let error as WASIAbi.Errno { + #expect(error == .EBADF) + } + + try wasi.fd_close(fd: fd) + } + + @Test + func memoryFileSystemWithFileDescriptorWrite() throws { + #if canImport(System) && !os(WASI) && !os(Windows) + let tempDir = try TestSupport.TemporaryDirectory() + try tempDir.createFile(at: "target.txt", contents: "") + + let fd = try tempDir.openFile(at: "target.txt", .writeOnly) + defer { + try? fd.close() + } + + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + try fs.addFile(at: "/handle.txt", handle: fd) + + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withPreopens(["/": "/"])).underlying + let rootFd: WASIAbi.Fd = 3 + + let openedFd = try wasi.path_open( + dirFd: rootFd, + dirFlags: [], + path: "handle.txt", + oflags: [], + fsRightsBase: [.FD_WRITE], + fsRightsInheriting: [], + fdflags: [] + ) + + let memory = TestSupport.TestGuestMemory() + let writeData = Array("Via handle".utf8) + let iovecs = memory.writeIOVecs([writeData]) + + let nwritten = try wasi.fd_write(fileDescriptor: openedFd, ioVectors: iovecs) + #expect(nwritten == UInt32(writeData.count)) + + try wasi.fd_close(fd: openedFd) + + let content = try String(contentsOf: tempDir.url.appendingPathComponent("target.txt"), encoding: .utf8) + #expect(content == "Via handle") + #endif + } + + @Test + func memoryFileSystemSeekBeyondEnd() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + try fs.addFile(at: "/small.txt", content: Array("Small".utf8)) + + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withPreopens(["/": "/"])).underlying + let rootFd: WASIAbi.Fd = 3 + + let fd = try wasi.path_open( + dirFd: rootFd, + dirFlags: [], + path: "small.txt", + oflags: [], + fsRightsBase: [.FD_READ, .FD_WRITE, .FD_SEEK], + fsRightsInheriting: [], + fdflags: [] + ) + + let newPos = try wasi.fd_seek(fd: fd, offset: 100, whence: .SET) + #expect(newPos == 100) + + let memory = TestSupport.TestGuestMemory() + let writeData = Array("End".utf8) + let iovecs = memory.writeIOVecs([writeData]) + + let nwritten = try wasi.fd_write(fileDescriptor: fd, ioVectors: iovecs) + #expect(nwritten == UInt32(writeData.count)) + + let stat = try wasi.fd_filestat_get(fd: fd) + #expect(stat.size == 103) + + try wasi.fd_close(fd: fd) + } + + @Test + func stdioFileDescriptors() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withStdio().withPreopens(["/": "/"])).underlying + + let stdinStat = try wasi.fd_fdstat_get(fileDescriptor: 0) + #expect(stdinStat.fsRightsBase.contains(.FD_READ)) + #expect(!stdinStat.fsRightsBase.contains(.FD_WRITE)) + + let stdoutStat = try wasi.fd_fdstat_get(fileDescriptor: 1) + #expect(!stdoutStat.fsRightsBase.contains(.FD_READ)) + #expect(stdoutStat.fsRightsBase.contains(.FD_WRITE)) + + let stderrStat = try wasi.fd_fdstat_get(fileDescriptor: 2) + #expect(!stderrStat.fsRightsBase.contains(.FD_READ)) + #expect(stderrStat.fsRightsBase.contains(.FD_WRITE)) + } + + @Test + func stdoutWrite() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withStdio().withPreopens(["/": "/"])).underlying + + let memory = TestSupport.TestGuestMemory() + let writeData = Array("Hello, stdout!".utf8) + let iovecs = memory.writeIOVecs([writeData]) + + let nwritten = try wasi.fd_write(fileDescriptor: 1, ioVectors: iovecs) + #expect(nwritten == UInt32(writeData.count)) + } + + @Test + func stderrWrite() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withStdio().withPreopens(["/": "/"])).underlying + + let memory = TestSupport.TestGuestMemory() + let writeData = Array("Error message".utf8) + let iovecs = memory.writeIOVecs([writeData]) + + let nwritten = try wasi.fd_write(fileDescriptor: 2, ioVectors: iovecs) + #expect(nwritten == UInt32(writeData.count)) + } + + @Test + func stdinCannotWrite() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withStdio().withPreopens(["/": "/"])).underlying + + let memory = TestSupport.TestGuestMemory() + let writeData = Array("Should fail".utf8) + let iovecs = memory.writeIOVecs([writeData]) + + do { + _ = try wasi.fd_write(fileDescriptor: 0, ioVectors: iovecs) + #expect(Bool(false), "Should not be able to write to stdin") + } catch let error as WASIAbi.Errno { + #expect(error == .EBADF) + } + } + + @Test + func stdoutCannotRead() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withPreopens(["/": "/"])).underlying + + let memory = TestSupport.TestGuestMemory() + let iovecs = memory.readIOVecs(sizes: [10]) + + do { + _ = try wasi.fd_read(fd: 1, iovs: iovecs) + #expect(Bool(false), "Should not be able to read from stdout") + } catch let error as WASIAbi.Errno { + #expect(error == .EBADF) + } + } + + @Test + func stderrCannotRead() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withPreopens(["/": "/"])).underlying + + let memory = TestSupport.TestGuestMemory() + let iovecs = memory.readIOVecs(sizes: [10]) + + do { + _ = try wasi.fd_read(fd: 2, iovs: iovecs) + #expect(Bool(false), "Should not be able to read from stderr") + } catch let error as WASIAbi.Errno { + #expect(error == .EBADF) + } + } + + @Test + func memoryFileSystemFileTimestamps() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + try fs.addFile(at: "/file.txt", content: "test") + + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withPreopens(["/": "/"])).underlying + let rootFd: WASIAbi.Fd = 3 + + let stat1 = try wasi.path_filestat_get(dirFd: rootFd, flags: [], path: "file.txt") + #expect(stat1.atim > 0) + #expect(stat1.mtim > 0) + #expect(stat1.ctim > 0) + + let fd = try wasi.path_open( + dirFd: rootFd, + dirFlags: [], + path: "file.txt", + oflags: [], + fsRightsBase: [.FD_READ, .FD_WRITE], + fsRightsInheriting: [], + fdflags: [] + ) + + let memory = TestSupport.TestGuestMemory() + let readVecs = memory.readIOVecs(sizes: [4]) + _ = try wasi.fd_read(fd: fd, iovs: readVecs) + + let stat2 = try wasi.fd_filestat_get(fd: fd) + #expect(stat2.atim >= stat1.atim) + + let writeData = Array("more".utf8) + let writeVecs = memory.writeIOVecs([writeData]) + _ = try wasi.fd_write(fileDescriptor: fd, ioVectors: writeVecs) + + let stat3 = try wasi.fd_filestat_get(fd: fd) + #expect(stat3.mtim >= stat2.mtim) + + try wasi.fd_close(fd: fd) + } + + @Test + func memoryFileSystemDirectoryTimestamps() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withPreopens(["/": "/"])).underlying + let rootFd: WASIAbi.Fd = 3 + + try wasi.path_create_directory(dirFd: rootFd, path: "testdir") + + let stat1 = try wasi.path_filestat_get(dirFd: rootFd, flags: [], path: "testdir") + #expect(stat1.atim > 0) + #expect(stat1.mtim > 0) + + try fs.addFile(at: "/testdir/file.txt", content: []) + + let stat2 = try wasi.path_filestat_get(dirFd: rootFd, flags: [], path: "testdir") + #expect(stat2.mtim >= stat1.mtim) + } + + @Test + func memoryFileSystemSetTimes() throws { + let fs = try MemoryFileSystem() + try fs.ensureDirectory(at: "/") + try fs.addFile(at: "/file.txt", content: []) + + let wasi = try WASIBridgeToHost(fileSystem: .memory(fs).withPreopens(["/": "/"])).underlying + let rootFd: WASIAbi.Fd = 3 + + let specificTime: WASIAbi.Timestamp = 1_000_000_000_000_000_000 + + try wasi.path_filestat_set_times( + dirFd: rootFd, + flags: [], + path: "file.txt", + atim: specificTime, + mtim: specificTime, + fstFlags: [.ATIM, .MTIM] + ) + + let stat = try wasi.path_filestat_get(dirFd: rootFd, flags: [], path: "file.txt") + #expect(stat.atim == specificTime) + #expect(stat.mtim == specificTime) + } + }