From fe1b938bf8fe7c733ccd66acb396e013259e2472 Mon Sep 17 00:00:00 2001 From: andrewmd5 <1297077+andrewmd5@users.noreply.github.com> Date: Fri, 21 Nov 2025 14:11:49 +0900 Subject: [PATCH 1/7] in-memory file system for WASI with support for directories, files, and character devices --- Sources/WASI/CMakeLists.txt | 9 +- Sources/WASI/FileSystem.swift | 59 ++ .../MemoryFileSystem/MemoryDirEntry.swift | 153 +++ .../WASI/MemoryFileSystem/MemoryFSNodes.swift | 89 ++ .../MemoryFileSystem/MemoryFileEntry.swift | 309 ++++++ .../MemoryFileSystem/MemoryFileSystem.swift | 466 +++++++++ .../MemoryFileSystem/MemoryStdioFile.swift | 115 +++ Sources/WASI/Platform/HostFileSystem.swift | 130 +++ Sources/WASI/WASI.swift | 85 +- Tests/WASITests/TestSupport.swift | 113 +++ Tests/WASITests/WASITests.swift | 920 ++++++++++++++++++ 11 files changed, 2394 insertions(+), 54 deletions(-) create mode 100644 Sources/WASI/MemoryFileSystem/MemoryDirEntry.swift create mode 100644 Sources/WASI/MemoryFileSystem/MemoryFSNodes.swift create mode 100644 Sources/WASI/MemoryFileSystem/MemoryFileEntry.swift create mode 100644 Sources/WASI/MemoryFileSystem/MemoryFileSystem.swift create mode 100644 Sources/WASI/MemoryFileSystem/MemoryStdioFile.swift create mode 100644 Sources/WASI/Platform/HostFileSystem.swift diff --git a/Sources/WASI/CMakeLists.txt b/Sources/WASI/CMakeLists.txt index 74144a9f..d20fa2ba 100644 --- a/Sources/WASI/CMakeLists.txt +++ b/Sources/WASI/CMakeLists.txt @@ -6,12 +6,19 @@ 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 + MemoryFileSystem/MemoryStdioFile.swift FileSystem.swift GuestMemorySupport.swift Clock.swift RandomBufferGenerator.swift WASI.swift + WASIBridgeToMemory.swift ) target_link_wasmkit_libraries(WASI PUBLIC - WasmTypes SystemExtras) + WasmTypes SystemExtras) \ No newline at end of file diff --git a/Sources/WASI/FileSystem.swift b/Sources/WASI/FileSystem.swift index b6f64043..a211d367 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,55 @@ struct FdTable { } } } + +/// Content of a file that can be retrieved from the file system. +public enum FileContent { + case bytes([UInt8]) + case handle(FileDescriptor) +} + +/// Public protocol for file system providers that users interact with. +/// +/// This protocol exposes only user-facing methods for managing files and directories. +public protocol FileSystemProvider { + /// Adds a file to the file system with the given byte content. + func addFile(at path: String, content: [UInt8]) throws + + /// Adds a file to the file system with the given string content. + func addFile(at path: String, content: String) throws + + /// Adds a file to the file system backed by a file descriptor handle. + func addFile(at path: String, handle: FileDescriptor) throws + + /// Gets the content of a file at the specified path. + func getFile(at path: String) throws -> FileContent + + /// Removes a file from the file system. + func removeFile(at path: String) throws +} + +/// Internal protocol for file system implementations used by WASI. +/// +/// This protocol contains WASI-specific implementation details that should not +/// be exposed to library users. +internal protocol FileSystem { + /// Returns the list of pre-opened directory paths. + func getPreopenPaths() -> [String] + + /// Opens a directory and returns a WASIDir implementation. + func openDirectory(at path: 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 + + /// Creates a standard I/O file entry for stdin/stdout/stderr. + func createStdioFile(fd: FileDescriptor, accessMode: FileAccessMode) -> any WASIFile +} \ No newline at end of file diff --git a/Sources/WASI/MemoryFileSystem/MemoryDirEntry.swift b/Sources/WASI/MemoryFileSystem/MemoryDirEntry.swift new file mode 100644 index 00000000..3a049ead --- /dev/null +++ b/Sources/WASI/MemoryFileSystem/MemoryDirEntry.swift @@ -0,0 +1,153 @@ +import SystemPackage + +/// A WASIDir implementation backed by an in-memory directory node. +internal struct MemoryDirEntry: WASIDir { + let preopenPath: String? + let dirNode: MemoryDirectoryNode + let path: String + let fileSystem: MemoryFileSystem + + func attributes() throws -> WASIAbi.Filestat { + return WASIAbi.Filestat( + dev: 0, ino: 0, filetype: .DIRECTORY, + nlink: 1, size: 0, + atim: 0, mtim: 0, ctim: 0 + ) + } + + 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 { + // No-op for memory filesystem - timestamps not tracked + } + + 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 + + switch node.type { + case .directory: + fileType = .DIRECTORY + case .file: + fileType = .REGULAR_FILE + if let fileNode = node as? MemoryFileNode { + size = WASIAbi.FileSize(fileNode.size) + } + case .characterDevice: + fileType = .CHARACTER_DEVICE + } + + return WASIAbi.Filestat( + dev: 0, ino: 0, filetype: fileType, + nlink: 1, size: size, + atim: 0, mtim: 0, ctim: 0 + ) + } + + func setFilestatTimes( + path: String, + atim: WASIAbi.Timestamp, mtim: WASIAbi.Timestamp, + fstFlags: WASIAbi.FstFlags, symlinkFollow: Bool + ) throws { + // No-op for memory filesystem - timestamps not tracked + } +} \ No newline at end of file diff --git a/Sources/WASI/MemoryFileSystem/MemoryFSNodes.swift b/Sources/WASI/MemoryFileSystem/MemoryFSNodes.swift new file mode 100644 index 00000000..2eb3d79e --- /dev/null +++ b/Sources/WASI/MemoryFileSystem/MemoryFSNodes.swift @@ -0,0 +1,89 @@ +import SystemPackage + +/// Base protocol for all file system nodes in memory. +internal protocol MemFSNode: AnyObject { + var type: MemFSNodeType { get } +} + +/// Types of file system nodes. +internal enum MemFSNodeType { + case directory + case file + case characterDevice +} + +/// A directory node in the memory file system. +internal final class MemoryDirectoryNode: MemFSNode { + let type: MemFSNodeType = .directory + private var children: [String: MemFSNode] = [:] + + init() {} + + func getChild(name: String) -> MemFSNode? { + return children[name] + } + + func setChild(name: String, node: MemFSNode) { + children[name] = node + } + + @discardableResult + func removeChild(name: String) -> Bool { + return children.removeValue(forKey: name) != nil + } + + func listChildren() -> [String] { + return Array(children.keys).sorted() + } + + func childCount() -> Int { + return children.count + } +} + +/// A regular file node in the memory file system. +internal final class MemoryFileNode: MemFSNode { + let type: MemFSNodeType = .file + var content: FileContent + + init(content: FileContent) { + self.content = content + } + + convenience init(bytes: [UInt8]) { + self.init(content: .bytes(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 + } + } + } +} + +/// A character device node in the memory file system. +internal final class MemoryCharacterDeviceNode: MemFSNode { + let type: MemFSNodeType = .characterDevice + + enum Kind { + case null + } + + let kind: Kind + + init(kind: Kind) { + self.kind = kind + } +} \ No newline at end of file diff --git a/Sources/WASI/MemoryFileSystem/MemoryFileEntry.swift b/Sources/WASI/MemoryFileSystem/MemoryFileEntry.swift new file mode 100644 index 00000000..d711f3e3 --- /dev/null +++ b/Sources/WASI/MemoryFileSystem/MemoryFileEntry.swift @@ -0,0 +1,309 @@ +import SystemPackage + +/// A WASIFile implementation for regular files in the memory file system. +internal 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 { + return WASIAbi.Filestat( + dev: 0, ino: 0, filetype: .REGULAR_FILE, + nlink: 1, size: WASIAbi.FileSize(fileNode.size), + atim: 0, mtim: 0, ctim: 0 + ) + } + + 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 { + // No-op for memory filesystem - timestamps not tracked + } + + 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) + + 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 + + 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) + + 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 + + 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) + } + } + + 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 + } +} \ No newline at end of file diff --git a/Sources/WASI/MemoryFileSystem/MemoryFileSystem.swift b/Sources/WASI/MemoryFileSystem/MemoryFileSystem.swift new file mode 100644 index 00000000..240344b5 --- /dev/null +++ b/Sources/WASI/MemoryFileSystem/MemoryFileSystem.swift @@ -0,0 +1,466 @@ +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(preopens: ["/": "/"]) +/// 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: FileSystemProvider, FileSystem { + private static let rootPath = "/" + + private var root: MemoryDirectoryNode + private let preopenPaths: [String] + + /// Creates a new in-memory file system. + /// + /// - Parameter preopens: Dictionary mapping guest paths to host paths. + /// Since this is a memory file system, host paths are ignored and only + /// guest paths are used to determine pre-opened directories. + public init(preopens: [String: String]? = nil) throws { + self.root = MemoryDirectoryNode() + + if let preopens = preopens { + self.preopenPaths = Array(preopens.keys).sorted() + } else { + self.preopenPaths = [Self.rootPath] + } + + for guestPath in self.preopenPaths { + try ensureDirectory(at: guestPath) + } + + let devDir = try ensureDirectory(at: "/dev") + devDir.setChild(name: "null", node: MemoryCharacterDeviceNode(kind: .null)) + } + + // MARK: - FileSystemProvider (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 byte array + public func addFile(at path: String, content: [UInt8]) 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: Array(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: - FileSystem (Internal WASI API) + + internal func getPreopenPaths() -> [String] { + return preopenPaths + } + + internal func openDirectory(at path: String) throws -> any WASIDir { + guard let node = lookup(at: path) else { + throw WASIAbi.Errno.ENOENT + } + + guard let dirNode = node as? MemoryDirectoryNode else { + throw WASIAbi.Errno.ENOTDIR + } + + return MemoryDirEntry( + preopenPath: preopenPaths.contains(path) ? path : nil, + dirNode: dirNode, + path: path, + fileSystem: self + ) + } + + internal 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 + } + } + + 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 + )) + } else 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)) + } else { + throw WASIAbi.Errno.ENOTSUP + } + } + + internal func createStdioFile(fd: FileDescriptor, accessMode: FileAccessMode) -> any WASIFile { + return MemoryStdioFile(fd: fd, accessMode: accessMode) + } + + // MARK: - Internal File Operations + + internal 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 + } + + internal 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 + internal 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 + internal 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 + } + } + + internal 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) + } + + internal 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) + } +} \ No newline at end of file diff --git a/Sources/WASI/MemoryFileSystem/MemoryStdioFile.swift b/Sources/WASI/MemoryFileSystem/MemoryStdioFile.swift new file mode 100644 index 00000000..fdc535a9 --- /dev/null +++ b/Sources/WASI/MemoryFileSystem/MemoryStdioFile.swift @@ -0,0 +1,115 @@ +import SystemPackage + +struct MemoryStdioFile: WASIFile { + let fd: FileDescriptor + let accessMode: FileAccessMode + + func attributes() throws -> WASIAbi.Filestat { + return WASIAbi.Filestat( + dev: 0, ino: 0, filetype: .CHARACTER_DEVICE, + nlink: 0, 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 stdio + } + + func advise( + offset: WASIAbi.FileSize, length: WASIAbi.FileSize, advice: WASIAbi.Advice + ) throws { + // No-op for stdio + } + + func close() throws { + // Don't actually close stdio file descriptors + } + + 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 stdio + } + + func setFilestatSize(_ size: WASIAbi.FileSize) throws { + throw WASIAbi.Errno.EINVAL + } + + func sync() throws { + try fd.sync() + } + + func datasync() throws { + try fd.datasync() + } + + 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 + } + + var bytesWritten: UInt32 = 0 + for iovec in buffer { + bytesWritten += try iovec.withHostBufferPointer { + UInt32(try fd.write(UnsafeRawBufferPointer($0))) + } + } + return bytesWritten + } + + 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 + } + + var nread: UInt32 = 0 + for iovec in buffer { + nread += try iovec.withHostBufferPointer { + try UInt32(fd.read(into: $0)) + } + } + return nread + } + + func pread(into buffer: Buffer, offset: WASIAbi.FileSize) throws -> WASIAbi.Size where Buffer.Element == WASIAbi.IOVec { + throw WASIAbi.Errno.ESPIPE + } +} \ No newline at end of file diff --git a/Sources/WASI/Platform/HostFileSystem.swift b/Sources/WASI/Platform/HostFileSystem.swift new file mode 100644 index 00000000..5910f300 --- /dev/null +++ b/Sources/WASI/Platform/HostFileSystem.swift @@ -0,0 +1,130 @@ +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, +/// with appropriate sandboxing through pre-opened directories. +public final class HostFileSystem: FileSystemProvider, FileSystem { + private let preopens: [String: String] + + /// Creates a new host file system with the specified pre-opened directories. + /// + /// - Parameter preopens: Dictionary mapping guest paths to host paths + public init(preopens: [String: String] = [:]) { + self.preopens = preopens + } + + // MARK: - FileSystemProvider (Public API) + + public func addFile(at path: String, content: [UInt8]) throws { + throw WASIAbi.Errno.ENOTSUP + } + + public func addFile(at path: String, content: String) throws { + throw WASIAbi.Errno.ENOTSUP + } + + public func addFile(at path: String, handle: FileDescriptor) throws { + throw WASIAbi.Errno.ENOTSUP + } + + public func getFile(at path: String) throws -> FileContent { + throw WASIAbi.Errno.ENOTSUP + } + + public func removeFile(at path: String) throws { + throw WASIAbi.Errno.ENOTSUP + } + + // MARK: - FileSystem (Internal WASI API) + + internal func getPreopenPaths() -> [String] { + return Array(preopens.keys).sorted() + } + + internal func openDirectory(at path: String) throws -> any WASIDir { + guard let hostPath = preopens[path] else { + throw WASIAbi.Errno.ENOENT + } + + #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: path, fd: fd) + } + + internal 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 + } + + internal func createStdioFile(fd: FileDescriptor, accessMode: FileAccessMode) -> any WASIFile { + return StdioFileEntry(fd: fd, accessMode: accessMode) + } +} \ No newline at end of file diff --git a/Sources/WASI/WASI.swift b/Sources/WASI/WASI.swift index 5bd648ce..cfe139fc 100644 --- a/Sources/WASI/WASI.swift +++ b/Sources/WASI/WASI.swift @@ -1373,10 +1373,12 @@ public class WASIBridgeToHost: WASI { private let wallClock: WallClock private let monotonicClock: MonotonicClock private var randomGenerator: RandomBufferGenerator + private let fileSystem: FileSystem public init( args: [String] = [], environment: [String: String] = [:], + fileSystemProvider: (any FileSystemProvider)? = nil, preopens: [String: String] = [:], stdin: FileDescriptor = .standardInput, stdout: FileDescriptor = .standardOutput, @@ -1387,29 +1389,25 @@ public class WASIBridgeToHost: WASI { ) throws { self.args = args self.environment = environment + if let provider = fileSystemProvider { + guard let fs = provider as? FileSystem else { + throw WASIError(description: "Invalid file system provider") + } + self.fileSystem = fs + } else { + self.fileSystem = HostFileSystem(preopens: preopens) + } + 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 + fdTable[0] = .file(self.fileSystem.createStdioFile(fd: stdin, accessMode: .read)) + fdTable[1] = .file(self.fileSystem.createStdioFile(fd: stdout, accessMode: .write)) + fdTable[2] = .file(self.fileSystem.createStdioFile(fd: stderr, accessMode: .write)) - if try fd.attributes().fileType.isDirectory { - _ = try fdTable.push(.directory(DirEntry(preopenPath: guestPath, fd: fd))) - } + for preopenPath in self.fileSystem.getPreopenPaths() { + let dirEntry = try self.fileSystem.openDirectory(at: preopenPath) + _ = try fdTable.push(.directory(dirEntry)) } + self.fdTable = fdTable self.wallClock = wallClock self.monotonicClock = monotonicClock @@ -1774,41 +1772,22 @@ 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 } func path_readlink(fd: WASIAbi.Fd, path: String, buffer: UnsafeGuestBufferPointer) throws -> WASIAbi.Size { 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..90e9d7d2 100644 --- a/Tests/WASITests/WASITests.swift +++ b/Tests/WASITests/WASITests.swift @@ -134,4 +134,924 @@ struct WASITests { } } #endif + + @Test + func memoryFileSystem() throws { + let fs = try MemoryFileSystem(preopens: ["/": "/"]) + + #expect(fs.getPreopenPaths() == ["/"]) + #expect(fs.lookup(at: "/") != nil) + #expect(fs.lookup(at: "/dev/null") != nil) + + 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(preopens: ["/": "/"]) + try fs.addFile(at: "/test.txt", content: Array("Test Content".utf8)) + try fs.ensureDirectory(at: "/testdir") + + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + + 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(preopens: ["/": "/"]) + try fs.addFile(at: "/readwrite.txt", content: Array("Initial".utf8)) + + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + 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(preopens: ["/": "/"]) + + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + 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(preopens: ["/": "/"]) + + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + 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(preopens: ["/": "/"]) + try fs.addFile(at: "/existing.txt", content: []) + + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + 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(preopens: [ + "/": "/", + "/tmp": "/tmp", + "/data": "/data", + ]) + + let preopens = fs.getPreopenPaths() + #expect(preopens.count == 3) + #expect(preopens.contains("/")) + #expect(preopens.contains("/tmp")) + #expect(preopens.contains("/data")) + + #expect(fs.lookup(at: "/tmp") != nil) + #expect(fs.lookup(at: "/data") != nil) + } + + @Test + func memoryFileSystemPrestatOperations() throws { + let fs = try MemoryFileSystem(preopens: [ + "/sandbox": "/sandbox" + ]) + + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + + 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(preopens: ["/": "/"]) + + 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(preopens: ["/": "/"]) + 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(preopens: ["/": "/"]) + try fs.addFile(at: "/mounted.txt", handle: fd) + + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + 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( + preopens: ["/sandbox": tempDir.url.path] + ) + + 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(preopens: ["/": "/"]) + try memFS.addFile(at: "/memory.txt", content: "Memory content") + + // Using memory filesystem through unified bridge + let wasi = try WASIBridgeToHost(fileSystemProvider: memFS) + + 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(preopens: ["/": "/"]) + try fs.addFile(at: "/positions.txt", content: Array("0123456789".utf8)) + + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + 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(preopens: ["/": "/"]) + try fs.addFile(at: "/file.txt", content: Array("test".utf8)) + + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + 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(preopens: ["/": "/"]) + 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(preopens: ["/": "/"]) + 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(preopens: ["/": "/"]) + try fs.addFile(at: "/truncate.txt", content: Array("Long content here".utf8)) + + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + 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(preopens: ["/": "/"]) + try fs.addFile(at: "/expand.txt", content: Array("Hi".utf8)) + + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + 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(preopens: ["/": "/"]) + try fs.addFile(at: "/old.txt", content: Array("Content".utf8)) + + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + 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(preopens: ["/": "/"]) + try fs.addFile(at: "/file.txt", content: Array("test".utf8)) + try fs.ensureDirectory(at: "/subdir") + + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + 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(preopens: ["/": "/"]) + try fs.ensureDirectory(at: "/emptydir") + + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + 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(preopens: ["/": "/"]) + try fs.ensureDirectory(at: "/nonempty") + try fs.addFile(at: "/nonempty/file.txt", content: []) + + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + 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(preopens: ["/": "/"]) + try fs.addFile(at: "/sync.txt", content: []) + + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + 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(preopens: ["/": "/"]) + try fs.addFile(at: "/test.txt", content: []) + + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + 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(preopens: ["/": "/"]) + try fs.addFile(at: "/readonly.txt", content: Array("Read only".utf8)) + + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + 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(preopens: ["/": "/"]) + try fs.addFile(at: "/writeonly.txt", content: []) + + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + 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(preopens: ["/": "/"]) + try fs.addFile(at: "/handle.txt", handle: fd) + + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + 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(preopens: ["/": "/"]) + try fs.addFile(at: "/small.txt", content: Array("Small".utf8)) + + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + 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(preopens: ["/": "/"]) + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + + 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(preopens: ["/": "/"]) + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + + 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(preopens: ["/": "/"]) + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + + 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(preopens: ["/": "/"]) + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + + 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(preopens: ["/": "/"]) + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + + 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(preopens: ["/": "/"]) + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + + 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) + } + } + + } From ff46f85616cff2e3f66f5a61e11aea4c519b5368 Mon Sep 17 00:00:00 2001 From: andrewmd5 <1297077+andrewmd5@users.noreply.github.com> Date: Wed, 26 Nov 2025 19:03:45 +0900 Subject: [PATCH 2/7] fixups --- Sources/SystemExtras/FileOperations.swift | 5 +- .../WASI/MemoryFileSystem/MemoryFSNodes.swift | 127 ++++++++++++++++++ .../MemoryFileSystem/MemoryFileSystem.swift | 73 ++++++---- .../WASIBridgeToHost+WasmKit.swift | 1 + 4 files changed, 178 insertions(+), 28 deletions(-) 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/MemoryFileSystem/MemoryFSNodes.swift b/Sources/WASI/MemoryFileSystem/MemoryFSNodes.swift index 2eb3d79e..9c5fc1bf 100644 --- a/Sources/WASI/MemoryFileSystem/MemoryFSNodes.swift +++ b/Sources/WASI/MemoryFileSystem/MemoryFSNodes.swift @@ -86,4 +86,131 @@ internal final class MemoryCharacterDeviceNode: MemFSNode { init(kind: Kind) { self.kind = kind } +} + +/// A WASIFile implementation for character devices like /dev/null +internal 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: + // /dev/null discards all writes but reports them as successful + 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: + // /dev/null always returns EOF (0 bytes read) + return 0 + } + } + + func pread(into buffer: Buffer, offset: WASIAbi.FileSize) throws -> WASIAbi.Size where Buffer.Element == WASIAbi.IOVec { + throw WASIAbi.Errno.ESPIPE + } } \ No newline at end of file diff --git a/Sources/WASI/MemoryFileSystem/MemoryFileSystem.swift b/Sources/WASI/MemoryFileSystem/MemoryFileSystem.swift index 240344b5..1f80cef6 100644 --- a/Sources/WASI/MemoryFileSystem/MemoryFileSystem.swift +++ b/Sources/WASI/MemoryFileSystem/MemoryFileSystem.swift @@ -18,7 +18,7 @@ import SystemPackage /// ``` public final class MemoryFileSystem: FileSystemProvider, FileSystem { private static let rootPath = "/" - + private var root: MemoryDirectoryNode private let preopenPaths: [String] @@ -45,7 +45,7 @@ public final class MemoryFileSystem: FileSystemProvider, FileSystem { } // MARK: - FileSystemProvider (Public API) - + /// Adds a file to the file system with the given byte content. /// /// - Parameters: @@ -114,20 +114,20 @@ public final class MemoryFileSystem: FileSystemProvider, FileSystem { } // MARK: - FileSystem (Internal WASI API) - + internal func getPreopenPaths() -> [String] { return preopenPaths } - + internal func openDirectory(at path: String) throws -> any WASIDir { guard let node = lookup(at: path) else { throw WASIAbi.Errno.ENOENT } - + guard let dirNode = node as? MemoryDirectoryNode else { throw WASIAbi.Errno.ENOTDIR } - + return MemoryDirEntry( preopenPath: preopenPaths.contains(path) ? path : nil, dirNode: dirNode, @@ -135,7 +135,7 @@ public final class MemoryFileSystem: FileSystemProvider, FileSystem { fileSystem: self ) } - + internal func openAt( dirFd: any WASIDir, path: String, @@ -148,11 +148,11 @@ public final class MemoryFileSystem: FileSystemProvider, FileSystem { 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 @@ -164,36 +164,41 @@ public final class MemoryFileSystem: FileSystemProvider, FileSystem { 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 - )) - } else if resolvedNode.type == .file { + 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) @@ -201,13 +206,27 @@ public final class MemoryFileSystem: FileSystemProvider, FileSystem { if fsRightsBase.contains(.FD_WRITE) { accessMode.insert(.write) } - + return .file(MemoryFileEntry(fileNode: fileNode, accessMode: accessMode, position: 0)) - } else { - throw WASIAbi.Errno.ENOTSUP } + 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 } - + internal func createStdioFile(fd: FileDescriptor, accessMode: FileAccessMode) -> any WASIFile { return MemoryStdioFile(fd: fd, accessMode: accessMode) } @@ -463,4 +482,4 @@ public final class MemoryFileSystem: FileSystemProvider, FileSystem { let parentPath = Self.rootPath + parentComponents.joined(separator: "/") return (parentPath, fileName) } -} \ No newline at end of file +} 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 { From 1183a12cde3d7352f419506a083a9a8686b42f3d Mon Sep 17 00:00:00 2001 From: andrewmd5 <1297077+andrewmd5@users.noreply.github.com> Date: Wed, 26 Nov 2025 19:47:24 +0900 Subject: [PATCH 3/7] formatting --- Sources/WASI/FileSystem.swift | 18 +-- .../MemoryFileSystem/MemoryDirEntry.swift | 50 ++++----- .../WASI/MemoryFileSystem/MemoryFSNodes.swift | 74 ++++++------ .../MemoryFileSystem/MemoryFileEntry.swift | 106 +++++++++--------- .../MemoryFileSystem/MemoryStdioFile.swift | 42 +++---- Sources/WASI/Platform/HostFileSystem.swift | 38 +++---- 6 files changed, 164 insertions(+), 164 deletions(-) diff --git a/Sources/WASI/FileSystem.swift b/Sources/WASI/FileSystem.swift index a211d367..bfccb859 100644 --- a/Sources/WASI/FileSystem.swift +++ b/Sources/WASI/FileSystem.swift @@ -83,7 +83,7 @@ enum FdEntry { return directory } } - + func asFile() -> (any WASIFile)? { if case .file(let entry) = self { return entry @@ -140,16 +140,16 @@ public enum FileContent { public protocol FileSystemProvider { /// Adds a file to the file system with the given byte content. func addFile(at path: String, content: [UInt8]) throws - + /// Adds a file to the file system with the given string content. func addFile(at path: String, content: String) throws - + /// Adds a file to the file system backed by a file descriptor handle. func addFile(at path: String, handle: FileDescriptor) throws - + /// Gets the content of a file at the specified path. func getFile(at path: String) throws -> FileContent - + /// Removes a file from the file system. func removeFile(at path: String) throws } @@ -161,10 +161,10 @@ public protocol FileSystemProvider { internal protocol FileSystem { /// Returns the list of pre-opened directory paths. func getPreopenPaths() -> [String] - + /// Opens a directory and returns a WASIDir implementation. func openDirectory(at path: String) throws -> any WASIDir - + /// Opens a file or directory from a directory file descriptor. func openAt( dirFd: any WASIDir, @@ -175,7 +175,7 @@ internal protocol FileSystem { fdflags: WASIAbi.Fdflags, symlinkFollow: Bool ) throws -> FdEntry - + /// Creates a standard I/O file entry for stdin/stdout/stderr. func createStdioFile(fd: FileDescriptor, accessMode: FileAccessMode) -> any WASIFile -} \ No newline at end of file +} diff --git a/Sources/WASI/MemoryFileSystem/MemoryDirEntry.swift b/Sources/WASI/MemoryFileSystem/MemoryDirEntry.swift index 3a049ead..1f537098 100644 --- a/Sources/WASI/MemoryFileSystem/MemoryDirEntry.swift +++ b/Sources/WASI/MemoryFileSystem/MemoryDirEntry.swift @@ -6,7 +6,7 @@ internal struct MemoryDirEntry: WASIDir { let dirNode: MemoryDirectoryNode let path: String let fileSystem: MemoryFileSystem - + func attributes() throws -> WASIAbi.Filestat { return WASIAbi.Filestat( dev: 0, ino: 0, filetype: .DIRECTORY, @@ -14,32 +14,32 @@ internal struct MemoryDirEntry: WASIDir { atim: 0, mtim: 0, ctim: 0 ) } - + 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 { // No-op for memory filesystem - timestamps not tracked } - + 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, @@ -51,39 +51,39 @@ internal struct MemoryDirEntry: WASIDir { // 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 @@ -92,38 +92,38 @@ internal struct MemoryDirEntry: WASIDir { 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 - + switch node.type { case .directory: fileType = .DIRECTORY @@ -135,14 +135,14 @@ internal struct MemoryDirEntry: WASIDir { case .characterDevice: fileType = .CHARACTER_DEVICE } - + return WASIAbi.Filestat( dev: 0, ino: 0, filetype: fileType, nlink: 1, size: size, atim: 0, mtim: 0, ctim: 0 ) } - + func setFilestatTimes( path: String, atim: WASIAbi.Timestamp, mtim: WASIAbi.Timestamp, @@ -150,4 +150,4 @@ internal struct MemoryDirEntry: WASIDir { ) throws { // No-op for memory filesystem - timestamps not tracked } -} \ No newline at end of file +} diff --git a/Sources/WASI/MemoryFileSystem/MemoryFSNodes.swift b/Sources/WASI/MemoryFileSystem/MemoryFSNodes.swift index 9c5fc1bf..3fb5e601 100644 --- a/Sources/WASI/MemoryFileSystem/MemoryFSNodes.swift +++ b/Sources/WASI/MemoryFileSystem/MemoryFSNodes.swift @@ -16,26 +16,26 @@ internal enum MemFSNodeType { internal final class MemoryDirectoryNode: MemFSNode { let type: MemFSNodeType = .directory private var children: [String: MemFSNode] = [:] - + init() {} - + func getChild(name: String) -> MemFSNode? { return children[name] } - + func setChild(name: String, node: MemFSNode) { children[name] = node } - + @discardableResult func removeChild(name: String) -> Bool { return children.removeValue(forKey: name) != nil } - + func listChildren() -> [String] { return Array(children.keys).sorted() } - + func childCount() -> Int { return children.count } @@ -45,19 +45,19 @@ internal final class MemoryDirectoryNode: MemFSNode { internal final class MemoryFileNode: MemFSNode { let type: MemFSNodeType = .file var content: FileContent - + init(content: FileContent) { self.content = content } - + convenience init(bytes: [UInt8]) { self.init(content: .bytes(bytes)) } - + convenience init(handle: FileDescriptor) { self.init(content: .handle(handle)) } - + var size: Int { switch content { case .bytes(let bytes): @@ -76,13 +76,13 @@ internal final class MemoryFileNode: MemFSNode { /// A character device node in the memory file system. internal final class MemoryCharacterDeviceNode: MemFSNode { let type: MemFSNodeType = .characterDevice - + enum Kind { case null } - + let kind: Kind - + init(kind: Kind) { self.kind = kind } @@ -92,14 +92,14 @@ internal final class MemoryCharacterDeviceNode: MemFSNode { internal 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, @@ -107,34 +107,34 @@ internal final class MemoryCharacterDeviceEntry: WASIFile { 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) { @@ -143,7 +143,7 @@ internal final class MemoryCharacterDeviceEntry: WASIFile { if accessMode.contains(.write) { fsRightsBase.insert(.FD_WRITE) } - + return WASIAbi.FdStat( fsFileType: .CHARACTER_DEVICE, fsFlags: [], @@ -151,36 +151,36 @@ internal final class MemoryCharacterDeviceEntry: WASIFile { 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: // /dev/null discards all writes but reports them as successful @@ -193,24 +193,24 @@ internal final class MemoryCharacterDeviceEntry: WASIFile { 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: // /dev/null always returns EOF (0 bytes read) return 0 } } - + func pread(into buffer: Buffer, offset: WASIAbi.FileSize) throws -> WASIAbi.Size where Buffer.Element == WASIAbi.IOVec { throw WASIAbi.Errno.ESPIPE } -} \ No newline at end of file +} diff --git a/Sources/WASI/MemoryFileSystem/MemoryFileEntry.swift b/Sources/WASI/MemoryFileSystem/MemoryFileEntry.swift index d711f3e3..f29334da 100644 --- a/Sources/WASI/MemoryFileSystem/MemoryFileEntry.swift +++ b/Sources/WASI/MemoryFileSystem/MemoryFileEntry.swift @@ -5,15 +5,15 @@ internal 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 { return WASIAbi.Filestat( dev: 0, ino: 0, filetype: .REGULAR_FILE, @@ -21,34 +21,34 @@ internal final class MemoryFileEntry: WASIFile { atim: 0, mtim: 0, ctim: 0 ) } - + 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 { // No-op for memory filesystem - timestamps not tracked } - + 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) { @@ -59,7 +59,7 @@ internal final class MemoryFileEntry: WASIFile { if accessMode.contains(.write) { fsRightsBase.insert(.FD_WRITE) } - + return WASIAbi.FdStat( fsFileType: .REGULAR_FILE, fsFlags: [], @@ -67,11 +67,11 @@ internal final class MemoryFileEntry: WASIFile { 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): @@ -82,31 +82,31 @@ internal final class MemoryFileEntry: WASIFile { bytes.append(contentsOf: Array(repeating: 0, count: newSize - bytes.count)) } fileNode.content = .bytes(bytes) - + 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 { @@ -117,7 +117,7 @@ internal final class MemoryFileEntry: WASIFile { case .END: newPosition = bytes.count + Int(offset) } - + case .handle(let handle): let platformWhence: FileDescriptor.SeekOrigin switch whence { @@ -132,22 +132,22 @@ internal final class MemoryFileEntry: WASIFile { 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 @@ -155,11 +155,11 @@ internal final class MemoryFileEntry: WASIFile { 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) @@ -167,7 +167,7 @@ internal final class MemoryFileEntry: WASIFile { } fileNode.content = .bytes(bytes) position = currentPosition - + case .handle(let handle): var currentOffset = Int64(position) for iovec in buffer { @@ -179,17 +179,17 @@ internal final class MemoryFileEntry: WASIFile { } 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) @@ -197,18 +197,18 @@ internal final class MemoryFileEntry: WASIFile { 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) - + case .handle(let handle): var currentOffset = Int64(offset) for iovec in buffer { @@ -219,17 +219,17 @@ internal final class MemoryFileEntry: WASIFile { 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 @@ -237,20 +237,20 @@ internal final class MemoryFileEntry: WASIFile { 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 - + case .handle(let handle): var currentOffset = Int64(position) for iovec in buffer { @@ -262,17 +262,17 @@ internal final class MemoryFileEntry: WASIFile { } 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) @@ -280,19 +280,19 @@ internal final class MemoryFileEntry: WASIFile { 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) } } - + case .handle(let handle): var currentOffset = Int64(offset) for iovec in buffer { @@ -303,7 +303,7 @@ internal final class MemoryFileEntry: WASIFile { totalRead += UInt32(nread) } } - + return totalRead } -} \ No newline at end of file +} diff --git a/Sources/WASI/MemoryFileSystem/MemoryStdioFile.swift b/Sources/WASI/MemoryFileSystem/MemoryStdioFile.swift index fdc535a9..4199f566 100644 --- a/Sources/WASI/MemoryFileSystem/MemoryStdioFile.swift +++ b/Sources/WASI/MemoryFileSystem/MemoryStdioFile.swift @@ -3,39 +3,39 @@ import SystemPackage struct MemoryStdioFile: WASIFile { let fd: FileDescriptor let accessMode: FileAccessMode - + func attributes() throws -> WASIAbi.Filestat { return WASIAbi.Filestat( dev: 0, ino: 0, filetype: .CHARACTER_DEVICE, nlink: 0, 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 stdio } - + func advise( offset: WASIAbi.FileSize, length: WASIAbi.FileSize, advice: WASIAbi.Advice ) throws { // No-op for stdio } - + func close() throws { // Don't actually close stdio file descriptors } - + func fdStat() throws -> WASIAbi.FdStat { var fsRightsBase: WASIAbi.Rights = [] if accessMode.contains(.read) { @@ -44,7 +44,7 @@ struct MemoryStdioFile: WASIFile { if accessMode.contains(.write) { fsRightsBase.insert(.FD_WRITE) } - + return WASIAbi.FdStat( fsFileType: .CHARACTER_DEVICE, fsFlags: [], @@ -52,36 +52,36 @@ struct MemoryStdioFile: WASIFile { fsRightsInheriting: [] ) } - + func setFdStatFlags(_ flags: WASIAbi.Fdflags) throws { // No-op for stdio } - + func setFilestatSize(_ size: WASIAbi.FileSize) throws { throw WASIAbi.Errno.EINVAL } - + func sync() throws { try fd.sync() } - + func datasync() throws { try fd.datasync() } - + 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 } - + var bytesWritten: UInt32 = 0 for iovec in buffer { bytesWritten += try iovec.withHostBufferPointer { @@ -90,16 +90,16 @@ struct MemoryStdioFile: WASIFile { } return bytesWritten } - + 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 } - + var nread: UInt32 = 0 for iovec in buffer { nread += try iovec.withHostBufferPointer { @@ -108,8 +108,8 @@ struct MemoryStdioFile: WASIFile { } return nread } - + func pread(into buffer: Buffer, offset: WASIAbi.FileSize) throws -> WASIAbi.Size where Buffer.Element == WASIAbi.IOVec { throw WASIAbi.Errno.ESPIPE } -} \ No newline at end of file +} diff --git a/Sources/WASI/Platform/HostFileSystem.swift b/Sources/WASI/Platform/HostFileSystem.swift index 5910f300..8418ee40 100644 --- a/Sources/WASI/Platform/HostFileSystem.swift +++ b/Sources/WASI/Platform/HostFileSystem.swift @@ -22,47 +22,47 @@ import SystemPackage /// with appropriate sandboxing through pre-opened directories. public final class HostFileSystem: FileSystemProvider, FileSystem { private let preopens: [String: String] - + /// Creates a new host file system with the specified pre-opened directories. /// /// - Parameter preopens: Dictionary mapping guest paths to host paths public init(preopens: [String: String] = [:]) { self.preopens = preopens } - + // MARK: - FileSystemProvider (Public API) - + public func addFile(at path: String, content: [UInt8]) throws { throw WASIAbi.Errno.ENOTSUP } - + public func addFile(at path: String, content: String) throws { throw WASIAbi.Errno.ENOTSUP } - + public func addFile(at path: String, handle: FileDescriptor) throws { throw WASIAbi.Errno.ENOTSUP } - + public func getFile(at path: String) throws -> FileContent { throw WASIAbi.Errno.ENOTSUP } - + public func removeFile(at path: String) throws { throw WASIAbi.Errno.ENOTSUP } - + // MARK: - FileSystem (Internal WASI API) - + internal func getPreopenPaths() -> [String] { return Array(preopens.keys).sorted() } - + internal func openDirectory(at path: String) throws -> any WASIDir { guard let hostPath = preopens[path] else { throw WASIAbi.Errno.ENOENT } - + #if os(Windows) || os(WASI) let fd = try FileDescriptor.open(FilePath(hostPath), .readWrite) #else @@ -75,14 +75,14 @@ public final class HostFileSystem: FileSystemProvider, FileSystem { return FileDescriptor(rawValue: fd) } #endif - + guard try fd.attributes().fileType.isDirectory else { throw WASIAbi.Errno.ENOTDIR } - + return DirEntry(preopenPath: path, fd: fd) } - + internal func openAt( dirFd: any WASIDir, path: String, @@ -102,7 +102,7 @@ public final class HostFileSystem: FileSystemProvider, FileSystem { if fsRightsBase.contains(.FD_WRITE) { accessMode.insert(.write) } - + let hostFd = try dirFd.openFile( symlinkFollow: symlinkFollow, path: path, @@ -110,12 +110,12 @@ public final class HostFileSystem: FileSystemProvider, FileSystem { 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 { @@ -123,8 +123,8 @@ public final class HostFileSystem: FileSystemProvider, FileSystem { } #endif } - + internal func createStdioFile(fd: FileDescriptor, accessMode: FileAccessMode) -> any WASIFile { return StdioFileEntry(fd: fd, accessMode: accessMode) } -} \ No newline at end of file +} From efad28d52331953461e588b4a3bb7506b3f36756 Mon Sep 17 00:00:00 2001 From: andrewmd5 <1297077+andrewmd5@users.noreply.github.com> Date: Wed, 26 Nov 2025 19:48:17 +0900 Subject: [PATCH 4/7] remove file --- Sources/WASI/CMakeLists.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/WASI/CMakeLists.txt b/Sources/WASI/CMakeLists.txt index d20fa2ba..a1392295 100644 --- a/Sources/WASI/CMakeLists.txt +++ b/Sources/WASI/CMakeLists.txt @@ -17,7 +17,6 @@ add_wasmkit_library(WASI Clock.swift RandomBufferGenerator.swift WASI.swift - WASIBridgeToMemory.swift ) target_link_wasmkit_libraries(WASI PUBLIC From 7d916fecfe97311a5c0a91b843c8520e2d822316 Mon Sep 17 00:00:00 2001 From: andrewmd5 <1297077+andrewmd5@users.noreply.github.com> Date: Wed, 26 Nov 2025 19:49:34 +0900 Subject: [PATCH 5/7] apply formatting --- Tests/WASITests/WASITests.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Tests/WASITests/WASITests.swift b/Tests/WASITests/WASITests.swift index 90e9d7d2..942273e3 100644 --- a/Tests/WASITests/WASITests.swift +++ b/Tests/WASITests/WASITests.swift @@ -960,7 +960,7 @@ struct WASITests { try wasi.fd_close(fd: fd) } - @Test + @Test func stdioFileDescriptors() throws { let fs = try MemoryFileSystem(preopens: ["/": "/"]) let wasi = try WASIBridgeToHost(fileSystemProvider: fs) @@ -1053,5 +1053,4 @@ struct WASITests { } } - } From 39b78a997968ac9e9df9f1bc14aeb0bbf19f6ca2 Mon Sep 17 00:00:00 2001 From: andrewmd5 <1297077+andrewmd5@users.noreply.github.com> Date: Fri, 28 Nov 2025 13:01:51 +0900 Subject: [PATCH 6/7] address reviewed items --- Sources/WASI/FileSystem.swift | 8 ++--- .../MemoryFileSystem/MemoryDirEntry.swift | 2 +- .../WASI/MemoryFileSystem/MemoryFSNodes.swift | 16 ++++----- .../MemoryFileSystem/MemoryFileEntry.swift | 2 +- .../MemoryFileSystem/MemoryFileSystem.swift | 36 +++++++++---------- Sources/WASI/Platform/HostFileSystem.swift | 15 ++++---- Sources/WASI/WASI.swift | 6 ++-- Tests/WASITests/WASITests.swift | 4 +-- 8 files changed, 43 insertions(+), 46 deletions(-) diff --git a/Sources/WASI/FileSystem.swift b/Sources/WASI/FileSystem.swift index bfccb859..6d98a0b6 100644 --- a/Sources/WASI/FileSystem.swift +++ b/Sources/WASI/FileSystem.swift @@ -137,9 +137,9 @@ public enum FileContent { /// Public protocol for file system providers that users interact with. /// /// This protocol exposes only user-facing methods for managing files and directories. -public protocol FileSystemProvider { +public protocol FileSystemProvider: ~Copyable { /// Adds a file to the file system with the given byte content. - func addFile(at path: String, content: [UInt8]) throws + func addFile(at path: String, content: some Sequence) throws /// Adds a file to the file system with the given string content. func addFile(at path: String, content: String) throws @@ -158,9 +158,9 @@ public protocol FileSystemProvider { /// /// This protocol contains WASI-specific implementation details that should not /// be exposed to library users. -internal protocol FileSystem { +protocol FileSystemImplementation: ~Copyable { /// Returns the list of pre-opened directory paths. - func getPreopenPaths() -> [String] + var preopenPaths: [String] { get } /// Opens a directory and returns a WASIDir implementation. func openDirectory(at path: String) throws -> any WASIDir diff --git a/Sources/WASI/MemoryFileSystem/MemoryDirEntry.swift b/Sources/WASI/MemoryFileSystem/MemoryDirEntry.swift index 1f537098..358df1ad 100644 --- a/Sources/WASI/MemoryFileSystem/MemoryDirEntry.swift +++ b/Sources/WASI/MemoryFileSystem/MemoryDirEntry.swift @@ -1,7 +1,7 @@ import SystemPackage /// A WASIDir implementation backed by an in-memory directory node. -internal struct MemoryDirEntry: WASIDir { +struct MemoryDirEntry: WASIDir { let preopenPath: String? let dirNode: MemoryDirectoryNode let path: String diff --git a/Sources/WASI/MemoryFileSystem/MemoryFSNodes.swift b/Sources/WASI/MemoryFileSystem/MemoryFSNodes.swift index 3fb5e601..bf1b58b6 100644 --- a/Sources/WASI/MemoryFileSystem/MemoryFSNodes.swift +++ b/Sources/WASI/MemoryFileSystem/MemoryFSNodes.swift @@ -1,19 +1,19 @@ import SystemPackage /// Base protocol for all file system nodes in memory. -internal protocol MemFSNode: AnyObject { +protocol MemFSNode: AnyObject { var type: MemFSNodeType { get } } /// Types of file system nodes. -internal enum MemFSNodeType { +enum MemFSNodeType { case directory case file case characterDevice } /// A directory node in the memory file system. -internal final class MemoryDirectoryNode: MemFSNode { +final class MemoryDirectoryNode: MemFSNode { let type: MemFSNodeType = .directory private var children: [String: MemFSNode] = [:] @@ -42,7 +42,7 @@ internal final class MemoryDirectoryNode: MemFSNode { } /// A regular file node in the memory file system. -internal final class MemoryFileNode: MemFSNode { +final class MemoryFileNode: MemFSNode { let type: MemFSNodeType = .file var content: FileContent @@ -50,8 +50,8 @@ internal final class MemoryFileNode: MemFSNode { self.content = content } - convenience init(bytes: [UInt8]) { - self.init(content: .bytes(bytes)) + convenience init(bytes: some Sequence) { + self.init(content: .bytes(Array(bytes))) } convenience init(handle: FileDescriptor) { @@ -74,7 +74,7 @@ internal final class MemoryFileNode: MemFSNode { } /// A character device node in the memory file system. -internal final class MemoryCharacterDeviceNode: MemFSNode { +final class MemoryCharacterDeviceNode: MemFSNode { let type: MemFSNodeType = .characterDevice enum Kind { @@ -89,7 +89,7 @@ internal final class MemoryCharacterDeviceNode: MemFSNode { } /// A WASIFile implementation for character devices like /dev/null -internal final class MemoryCharacterDeviceEntry: WASIFile { +final class MemoryCharacterDeviceEntry: WASIFile { let deviceNode: MemoryCharacterDeviceNode let accessMode: FileAccessMode diff --git a/Sources/WASI/MemoryFileSystem/MemoryFileEntry.swift b/Sources/WASI/MemoryFileSystem/MemoryFileEntry.swift index f29334da..a00ae6c8 100644 --- a/Sources/WASI/MemoryFileSystem/MemoryFileEntry.swift +++ b/Sources/WASI/MemoryFileSystem/MemoryFileEntry.swift @@ -1,7 +1,7 @@ import SystemPackage /// A WASIFile implementation for regular files in the memory file system. -internal final class MemoryFileEntry: WASIFile { +final class MemoryFileEntry: WASIFile { let fileNode: MemoryFileNode let accessMode: FileAccessMode var position: Int diff --git a/Sources/WASI/MemoryFileSystem/MemoryFileSystem.swift b/Sources/WASI/MemoryFileSystem/MemoryFileSystem.swift index 1f80cef6..c24b2c97 100644 --- a/Sources/WASI/MemoryFileSystem/MemoryFileSystem.swift +++ b/Sources/WASI/MemoryFileSystem/MemoryFileSystem.swift @@ -16,11 +16,11 @@ import SystemPackage /// let fd = try FileDescriptor.open("/path/to/file", .readOnly) /// try fs.addFile(at: "/mounted.txt", handle: fd) /// ``` -public final class MemoryFileSystem: FileSystemProvider, FileSystem { +public final class MemoryFileSystem: FileSystemProvider, FileSystemImplementation { private static let rootPath = "/" private var root: MemoryDirectoryNode - private let preopenPaths: [String] + let preopenPaths: [String] /// Creates a new in-memory file system. /// @@ -50,8 +50,8 @@ public final class MemoryFileSystem: FileSystemProvider, FileSystem { /// /// - Parameters: /// - path: The path where the file should be created - /// - content: The file content as byte array - public func addFile(at path: String, content: [UInt8]) throws { + /// - 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) @@ -65,7 +65,7 @@ public final class MemoryFileSystem: FileSystemProvider, FileSystem { /// - 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: Array(content.utf8)) + try addFile(at: path, content: content.utf8) } /// Adds a file to the file system backed by a file descriptor. @@ -113,13 +113,9 @@ public final class MemoryFileSystem: FileSystemProvider, FileSystem { } } - // MARK: - FileSystem (Internal WASI API) + // MARK: - FileSystemImplementation (WASI API) - internal func getPreopenPaths() -> [String] { - return preopenPaths - } - - internal func openDirectory(at path: String) throws -> any WASIDir { + func openDirectory(at path: String) throws -> any WASIDir { guard let node = lookup(at: path) else { throw WASIAbi.Errno.ENOENT } @@ -136,7 +132,7 @@ public final class MemoryFileSystem: FileSystemProvider, FileSystem { ) } - internal func openAt( + func openAt( dirFd: any WASIDir, path: String, oflags: WASIAbi.Oflags, @@ -227,13 +223,13 @@ public final class MemoryFileSystem: FileSystemProvider, FileSystem { throw WASIAbi.Errno.ENOTSUP } - internal func createStdioFile(fd: FileDescriptor, accessMode: FileAccessMode) -> any WASIFile { + func createStdioFile(fd: FileDescriptor, accessMode: FileAccessMode) -> any WASIFile { return MemoryStdioFile(fd: fd, accessMode: accessMode) } - // MARK: - Internal File Operations + // MARK: - File Operations - internal func lookup(at path: String) -> MemFSNode? { + func lookup(at path: String) -> MemFSNode? { let normalized = normalizePath(path) if normalized == Self.rootPath { @@ -256,7 +252,7 @@ public final class MemoryFileSystem: FileSystemProvider, FileSystem { return current } - internal func resolve(from directory: MemoryDirectoryNode, at directoryPath: String, path relativePath: String) -> MemFSNode? { + func resolve(from directory: MemoryDirectoryNode, at directoryPath: String, path relativePath: String) -> MemFSNode? { if relativePath.isEmpty { return directory } @@ -292,7 +288,7 @@ public final class MemoryFileSystem: FileSystemProvider, FileSystem { } @discardableResult - internal func ensureDirectory(at path: String) throws -> MemoryDirectoryNode { + func ensureDirectory(at path: String) throws -> MemoryDirectoryNode { let normalized = normalizePath(path) if normalized == Self.rootPath { @@ -342,7 +338,7 @@ public final class MemoryFileSystem: FileSystemProvider, FileSystem { } @discardableResult - internal func createFile(in directory: MemoryDirectoryNode, at relativePath: String, oflags: WASIAbi.Oflags) throws -> MemoryFileNode { + func createFile(in directory: MemoryDirectoryNode, at relativePath: String, oflags: WASIAbi.Oflags) throws -> MemoryFileNode { try validateRelativePath(relativePath) let components = relativePath.split(separator: "/").map(String.init) @@ -367,7 +363,7 @@ public final class MemoryFileSystem: FileSystemProvider, FileSystem { } } - internal func removeNode(in directory: MemoryDirectoryNode, at relativePath: String, mustBeDirectory: Bool) throws { + func removeNode(in directory: MemoryDirectoryNode, at relativePath: String, mustBeDirectory: Bool) throws { try validateRelativePath(relativePath) let components = relativePath.split(separator: "/").map(String.init) @@ -403,7 +399,7 @@ public final class MemoryFileSystem: FileSystemProvider, FileSystem { current.removeChild(name: fileName) } - internal func rename(from sourcePath: String, in sourceDir: MemoryDirectoryNode, to destPath: String, in destDir: MemoryDirectoryNode) throws { + 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 } diff --git a/Sources/WASI/Platform/HostFileSystem.swift b/Sources/WASI/Platform/HostFileSystem.swift index 8418ee40..f60b38b0 100644 --- a/Sources/WASI/Platform/HostFileSystem.swift +++ b/Sources/WASI/Platform/HostFileSystem.swift @@ -20,7 +20,8 @@ import SystemPackage /// /// This implementation provides access to actual files and directories on the host system, /// with appropriate sandboxing through pre-opened directories. -public final class HostFileSystem: FileSystemProvider, FileSystem { +public final class HostFileSystem: FileSystemProvider, FileSystemImplementation { + private let preopens: [String: String] /// Creates a new host file system with the specified pre-opened directories. @@ -32,7 +33,7 @@ public final class HostFileSystem: FileSystemProvider, FileSystem { // MARK: - FileSystemProvider (Public API) - public func addFile(at path: String, content: [UInt8]) throws { + public func addFile(at path: String, content: some Sequence) throws { throw WASIAbi.Errno.ENOTSUP } @@ -52,13 +53,13 @@ public final class HostFileSystem: FileSystemProvider, FileSystem { throw WASIAbi.Errno.ENOTSUP } - // MARK: - FileSystem (Internal WASI API) + // MARK: - FileSystemImplementation (WASI API) - internal func getPreopenPaths() -> [String] { + var preopenPaths: [String] { return Array(preopens.keys).sorted() } - internal func openDirectory(at path: String) throws -> any WASIDir { + func openDirectory(at path: String) throws -> any WASIDir { guard let hostPath = preopens[path] else { throw WASIAbi.Errno.ENOENT } @@ -83,7 +84,7 @@ public final class HostFileSystem: FileSystemProvider, FileSystem { return DirEntry(preopenPath: path, fd: fd) } - internal func openAt( + func openAt( dirFd: any WASIDir, path: String, oflags: WASIAbi.Oflags, @@ -124,7 +125,7 @@ public final class HostFileSystem: FileSystemProvider, FileSystem { #endif } - internal func createStdioFile(fd: FileDescriptor, accessMode: FileAccessMode) -> any WASIFile { + 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 cfe139fc..22e2b0bc 100644 --- a/Sources/WASI/WASI.swift +++ b/Sources/WASI/WASI.swift @@ -1373,7 +1373,7 @@ public class WASIBridgeToHost: WASI { private let wallClock: WallClock private let monotonicClock: MonotonicClock private var randomGenerator: RandomBufferGenerator - private let fileSystem: FileSystem + private let fileSystem: FileSystemImplementation public init( args: [String] = [], @@ -1390,7 +1390,7 @@ public class WASIBridgeToHost: WASI { self.args = args self.environment = environment if let provider = fileSystemProvider { - guard let fs = provider as? FileSystem else { + guard let fs = provider as? FileSystemImplementation else { throw WASIError(description: "Invalid file system provider") } self.fileSystem = fs @@ -1403,7 +1403,7 @@ public class WASIBridgeToHost: WASI { fdTable[1] = .file(self.fileSystem.createStdioFile(fd: stdout, accessMode: .write)) fdTable[2] = .file(self.fileSystem.createStdioFile(fd: stderr, accessMode: .write)) - for preopenPath in self.fileSystem.getPreopenPaths() { + for preopenPath in self.fileSystem.preopenPaths { let dirEntry = try self.fileSystem.openDirectory(at: preopenPath) _ = try fdTable.push(.directory(dirEntry)) } diff --git a/Tests/WASITests/WASITests.swift b/Tests/WASITests/WASITests.swift index 942273e3..346dece5 100644 --- a/Tests/WASITests/WASITests.swift +++ b/Tests/WASITests/WASITests.swift @@ -139,7 +139,7 @@ struct WASITests { func memoryFileSystem() throws { let fs = try MemoryFileSystem(preopens: ["/": "/"]) - #expect(fs.getPreopenPaths() == ["/"]) + #expect(fs.preopenPaths == ["/"]) #expect(fs.lookup(at: "/") != nil) #expect(fs.lookup(at: "/dev/null") != nil) @@ -329,7 +329,7 @@ struct WASITests { "/data": "/data", ]) - let preopens = fs.getPreopenPaths() + let preopens = fs.preopenPaths #expect(preopens.count == 3) #expect(preopens.contains("/")) #expect(preopens.contains("/tmp")) From da9d0991b57505cce8954514b12c6c12dd4b44c5 Mon Sep 17 00:00:00 2001 From: andrewmd5 <1297077+andrewmd5@users.noreply.github.com> Date: Fri, 5 Dec 2025 12:43:05 +0900 Subject: [PATCH 7/7] feat: timestamp handling in MemoryFileSystem --- .../MemoryFileSystem/MemoryDirEntry.swift | 107 ++++++++++++++- .../WASI/MemoryFileSystem/MemoryFSNodes.swift | 129 +++++++++++++++++- .../MemoryFileSystem/MemoryFileEntry.swift | 62 ++++++++- Tests/WASITests/WASITests.swift | 83 +++++++++++ 4 files changed, 371 insertions(+), 10 deletions(-) diff --git a/Sources/WASI/MemoryFileSystem/MemoryDirEntry.swift b/Sources/WASI/MemoryFileSystem/MemoryDirEntry.swift index 358df1ad..5baca0a7 100644 --- a/Sources/WASI/MemoryFileSystem/MemoryDirEntry.swift +++ b/Sources/WASI/MemoryFileSystem/MemoryDirEntry.swift @@ -1,3 +1,4 @@ +import SystemExtras import SystemPackage /// A WASIDir implementation backed by an in-memory directory node. @@ -8,10 +9,13 @@ struct MemoryDirEntry: WASIDir { 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: 0, mtim: 0, ctim: 0 + atim: timestamps.atim, + mtim: timestamps.mtim, + ctim: timestamps.ctim ) } @@ -27,7 +31,26 @@ struct MemoryDirEntry: WASIDir { atim: WASIAbi.Timestamp, mtim: WASIAbi.Timestamp, fstFlags: WASIAbi.FstFlags ) throws { - // No-op for memory filesystem - timestamps not tracked + let now = currentTimestamp() + 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( @@ -123,14 +146,27 @@ struct MemoryDirEntry: WASIDir { 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 @@ -139,7 +175,7 @@ struct MemoryDirEntry: WASIDir { return WASIAbi.Filestat( dev: 0, ino: 0, filetype: fileType, nlink: 1, size: size, - atim: 0, mtim: 0, ctim: 0 + atim: atim, mtim: mtim, ctim: ctim ) } @@ -148,6 +184,69 @@ struct MemoryDirEntry: WASIDir { atim: WASIAbi.Timestamp, mtim: WASIAbi.Timestamp, fstFlags: WASIAbi.FstFlags, symlinkFollow: Bool ) throws { - // No-op for memory filesystem - timestamps not tracked + 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 = currentTimestamp() + 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 index bf1b58b6..97c7389a 100644 --- a/Sources/WASI/MemoryFileSystem/MemoryFSNodes.swift +++ b/Sources/WASI/MemoryFileSystem/MemoryFSNodes.swift @@ -1,5 +1,30 @@ import SystemPackage +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#elseif canImport(Musl) + import Musl +#elseif os(Windows) + import ucrt + import WinSDK +#endif + +func currentTimestamp() -> WASIAbi.Timestamp { + #if os(Windows) + var ft = FILETIME() + GetSystemTimeAsFileTime(&ft) + let intervals = (Int64(ft.dwHighDateTime) << 32) | Int64(ft.dwLowDateTime) + let unixIntervals = intervals - 116_444_736_000_000_000 + return WASIAbi.Timestamp(unixIntervals * 100) + #else + var ts = timespec() + clock_gettime(CLOCK_REALTIME, &ts) + return WASIAbi.Timestamp(ts.tv_sec) * 1_000_000_000 + WASIAbi.Timestamp(ts.tv_nsec) + #endif +} + /// Base protocol for all file system nodes in memory. protocol MemFSNode: AnyObject { var type: MemFSNodeType { get } @@ -17,7 +42,41 @@ final class MemoryDirectoryNode: MemFSNode { let type: MemFSNodeType = .directory private var children: [String: MemFSNode] = [:] - init() {} + private var _atim: WASIAbi.Timestamp + private var _mtim: WASIAbi.Timestamp + private var _ctim: WASIAbi.Timestamp + + init() { + let now = currentTimestamp() + 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 = currentTimestamp() + } + + func touchModificationTime() { + let now = currentTimestamp() + _mtim = now + _ctim = now + } + + func setTimes(atim: WASIAbi.Timestamp?, mtim: WASIAbi.Timestamp?) { + let now = currentTimestamp() + if let atim = atim { + _atim = atim + } + if let mtim = mtim { + _mtim = mtim + } + _ctim = now + } func getChild(name: String) -> MemFSNode? { return children[name] @@ -25,14 +84,20 @@ final class MemoryDirectoryNode: MemFSNode { func setChild(name: String, node: MemFSNode) { children[name] = node + touchModificationTime() } @discardableResult func removeChild(name: String) -> Bool { - return children.removeValue(forKey: name) != nil + let removed = children.removeValue(forKey: name) != nil + if removed { + touchModificationTime() + } + return removed } func listChildren() -> [String] { + touchAccessTime() return Array(children.keys).sorted() } @@ -46,8 +111,16 @@ 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 = currentTimestamp() + self._atim = now + self._mtim = now + self._ctim = now } convenience init(bytes: some Sequence) { @@ -71,6 +144,56 @@ final class MemoryFileNode: MemFSNode { } } } + + 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(attrs.accessTime.seconds) * 1_000_000_000 + + WASIAbi.Timestamp(attrs.accessTime.nanoseconds) + let mtim = + WASIAbi.Timestamp(attrs.modificationTime.seconds) * 1_000_000_000 + + WASIAbi.Timestamp(attrs.modificationTime.nanoseconds) + let ctim = + WASIAbi.Timestamp(attrs.creationTime.seconds) * 1_000_000_000 + + WASIAbi.Timestamp(attrs.creationTime.nanoseconds) + return (atim, mtim, ctim) + } catch { + return (0, 0, 0) + } + } + } + + func touchAccessTime() { + if case .bytes = content { + _atim = currentTimestamp() + } + } + + func touchModificationTime() { + if case .bytes = content { + let now = currentTimestamp() + _mtim = now + _ctim = now + } + } + + func setTimes(atim: WASIAbi.Timestamp?, mtim: WASIAbi.Timestamp?) { + if case .bytes = content { + let now = currentTimestamp() + if let atim = atim { + _atim = atim + } + if let mtim = mtim { + _mtim = mtim + } + _ctim = now + } + } } /// A character device node in the memory file system. @@ -183,7 +306,6 @@ final class MemoryCharacterDeviceEntry: WASIFile { switch deviceNode.kind { case .null: - // /dev/null discards all writes but reports them as successful var totalBytes: UInt32 = 0 for iovec in buffer { iovec.withHostBufferPointer { bufferPtr in @@ -205,7 +327,6 @@ final class MemoryCharacterDeviceEntry: WASIFile { switch deviceNode.kind { case .null: - // /dev/null always returns EOF (0 bytes read) return 0 } } diff --git a/Sources/WASI/MemoryFileSystem/MemoryFileEntry.swift b/Sources/WASI/MemoryFileSystem/MemoryFileEntry.swift index a00ae6c8..fedcfa14 100644 --- a/Sources/WASI/MemoryFileSystem/MemoryFileEntry.swift +++ b/Sources/WASI/MemoryFileSystem/MemoryFileEntry.swift @@ -1,3 +1,4 @@ +import SystemExtras import SystemPackage /// A WASIFile implementation for regular files in the memory file system. @@ -15,10 +16,13 @@ final class MemoryFileEntry: WASIFile { // 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: 0, mtim: 0, ctim: 0 + atim: timestamps.atim, + mtim: timestamps.mtim, + ctim: timestamps.ctim ) } @@ -34,7 +38,56 @@ final class MemoryFileEntry: WASIFile { atim: WASIAbi.Timestamp, mtim: WASIAbi.Timestamp, fstFlags: WASIAbi.FstFlags ) throws { - // No-op for memory filesystem - timestamps not tracked + switch fileNode.content { + case .bytes: + let now = currentTimestamp() + 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( @@ -82,6 +135,7 @@ final class MemoryFileEntry: WASIFile { 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)) @@ -167,6 +221,7 @@ final class MemoryFileEntry: WASIFile { } fileNode.content = .bytes(bytes) position = currentPosition + fileNode.touchModificationTime() case .handle(let handle): var currentOffset = Int64(position) @@ -208,6 +263,7 @@ final class MemoryFileEntry: WASIFile { } } fileNode.content = .bytes(bytes) + fileNode.touchModificationTime() case .handle(let handle): var currentOffset = Int64(offset) @@ -250,6 +306,7 @@ final class MemoryFileEntry: WASIFile { } } position = currentPosition + fileNode.touchAccessTime() case .handle(let handle): var currentOffset = Int64(position) @@ -292,6 +349,7 @@ final class MemoryFileEntry: WASIFile { totalRead += UInt32(toRead) } } + fileNode.touchAccessTime() case .handle(let handle): var currentOffset = Int64(offset) diff --git a/Tests/WASITests/WASITests.swift b/Tests/WASITests/WASITests.swift index 346dece5..635429ad 100644 --- a/Tests/WASITests/WASITests.swift +++ b/Tests/WASITests/WASITests.swift @@ -1053,4 +1053,87 @@ struct WASITests { } } + @Test + func memoryFileSystemFileTimestamps() throws { + let fs = try MemoryFileSystem(preopens: ["/": "/"]) + try fs.addFile(at: "/file.txt", content: "test") + + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + 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(preopens: ["/": "/"]) + + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + 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(preopens: ["/": "/"]) + try fs.addFile(at: "/file.txt", content: []) + + let wasi = try WASIBridgeToHost(fileSystemProvider: fs) + 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) + } + }