diff --git a/Sources/SystemExtras/FileAtOperations.swift b/Sources/SystemExtras/FileAtOperations.swift index 6d54c42b..5f8f3838 100644 --- a/Sources/SystemExtras/FileAtOperations.swift +++ b/Sources/SystemExtras/FileAtOperations.swift @@ -319,4 +319,57 @@ extension FileDescriptor { } #endif } + + /// Rename a file or directory relative to directory file descriptors + /// + /// - Parameters: + /// - oldPath: The relative location of the file or directory to rename. + /// - newDirFd: The directory file descriptor for the new path. + /// - newPath: The relative destination path to which to rename the file or directory. + /// + /// The corresponding C function is `renameat`. + @_alwaysEmitIntoClient + public func rename( + at oldPath: FilePath, + to newDirFd: FileDescriptor, + at newPath: FilePath + ) throws { + try oldPath.withPlatformString { cOldPath in + try newPath.withPlatformString { cNewPath in + try _rename(at: cOldPath, to: newDirFd, at: cNewPath).get() + } + } + } + + /// Rename a file or directory relative to directory file descriptors + /// + /// - Parameters: + /// - oldPath: The relative location of the file or directory to rename. + /// - newDirFd: The directory file descriptor for the new path. + /// - newPath: The relative destination path to which to rename the file or directory. + /// + /// The corresponding C function is `renameat`. + @_alwaysEmitIntoClient + public func rename( + at oldPath: UnsafePointer, + to newDirFd: FileDescriptor, + at newPath: UnsafePointer + ) throws { + try _rename(at: oldPath, to: newDirFd, at: newPath).get() + } + + @usableFromInline + internal func _rename( + at oldPath: UnsafePointer, + to newDirFd: FileDescriptor, + at newPath: UnsafePointer + ) -> Result<(), Errno> { + #if os(Windows) + return .failure(Errno(rawValue: ERROR_NOT_SUPPORTED)) + #else + return nothingOrErrno(retryOnInterrupt: false) { + system_renameat(self.rawValue, oldPath, newDirFd.rawValue, newPath) + } + #endif + } } diff --git a/Sources/SystemExtras/Syscalls.swift b/Sources/SystemExtras/Syscalls.swift index 0f681824..3a8aa578 100644 --- a/Sources/SystemExtras/Syscalls.swift +++ b/Sources/SystemExtras/Syscalls.swift @@ -97,6 +97,14 @@ internal func system_unlinkat( return unlinkat(fd, path, flags) } +// renameat +internal func system_renameat( + _ oldfd: Int32, _ oldpath: UnsafePointer, + _ newfd: Int32, _ newpath: UnsafePointer +) -> CInt { + return renameat(oldfd, oldpath, newfd, newpath) +} + // ftruncate internal func system_ftruncate(_ fd: Int32, _ size: off_t) -> CInt { return ftruncate(fd, size) diff --git a/Sources/WASI/FileSystem.swift b/Sources/WASI/FileSystem.swift index c3741c2b..b6f64043 100644 --- a/Sources/WASI/FileSystem.swift +++ b/Sources/WASI/FileSystem.swift @@ -61,6 +61,7 @@ protocol WASIDir: WASIEntry { func removeDirectory(atPath path: String) throws func removeFile(atPath path: String) throws func symlink(from sourcePath: String, to destPath: String) throws + func rename(from sourcePath: String, toDir newDir: any WASIDir, to destPath: String) throws func readEntries(cookie: WASIAbi.DirCookie) throws -> AnyIterator> func attributes(path: String, symlinkFollow: Bool) throws -> WASIAbi.Filestat func setFilestatTimes( diff --git a/Sources/WASI/Platform/Directory.swift b/Sources/WASI/Platform/Directory.swift index 787a7a46..64c49ae4 100644 --- a/Sources/WASI/Platform/Directory.swift +++ b/Sources/WASI/Platform/Directory.swift @@ -117,6 +117,44 @@ extension DirEntry: WASIDir, FdWASIEntry { } } + func rename(from sourcePath: String, toDir newDir: any WASIDir, to destPath: String) throws { + #if os(Windows) + throw WASIAbi.Errno.ENOSYS + #else + guard let newDir = newDir as? Self else { + throw WASIAbi.Errno.EBADF + } + + // As a special case, rename ignores a trailing slash rather than treating + // it as equivalent to a trailing slash-dot, so strip any trailing slashes + // for the purposes of openParent. + let oldHasTrailingSlash = SandboxPrimitives.pathHasTrailingSlash(sourcePath) + let newHasTrailingSlash = SandboxPrimitives.pathHasTrailingSlash(destPath) + + let oldPath = SandboxPrimitives.stripDirSuffix(sourcePath) + let newPath = SandboxPrimitives.stripDirSuffix(destPath) + + let (sourceDir, sourceBasename) = try SandboxPrimitives.openParent( + start: fd, path: oldPath + ) + let (destDir, destBasename) = try SandboxPrimitives.openParent( + start: newDir.fd, path: newPath + ) + + // Re-append a slash if the original path had one + let finalSourceBasename = oldHasTrailingSlash ? sourceBasename + "/" : sourceBasename + let finalDestBasename = newHasTrailingSlash ? destBasename + "/" : destBasename + + try WASIAbi.Errno.translatingPlatformErrno { + try sourceDir.rename( + at: FilePath(finalSourceBasename), + to: destDir, + at: FilePath(finalDestBasename) + ) + } + #endif + } + func readEntries( cookie: WASIAbi.DirCookie ) throws -> AnyIterator> { diff --git a/Sources/WASI/Platform/SandboxPrimitives/OpenParent.swift b/Sources/WASI/Platform/SandboxPrimitives/OpenParent.swift index e2e4566c..7321b7d5 100644 --- a/Sources/WASI/Platform/SandboxPrimitives/OpenParent.swift +++ b/Sources/WASI/Platform/SandboxPrimitives/OpenParent.swift @@ -31,6 +31,22 @@ internal func splitParent(path: String) -> (FilePath, FilePath.Component)? { } extension SandboxPrimitives { + /// Strip trailing slashes from a path, unless this reduces the path to "/" itself. + /// This is used by rename operations to prevent paths like "foo/" from canonicalizing + /// to "foo/." since these syscalls treat these differently. + static func stripDirSuffix(_ path: String) -> String { + var path = path + while path.count > 1 && path.hasSuffix("/") { + path = String(path.dropLast()) + } + return path + } + + /// Check if a path has trailing slashes + static func pathHasTrailingSlash(_ path: String) -> Bool { + return path.hasSuffix("/") + } + static func openParent(start: FileDescriptor, path: String) throws -> (FileDescriptor, String) { guard let (dirName, basename) = splitParent(path: path) else { throw WASIAbi.Errno.ENOENT diff --git a/Sources/WASI/WASI.swift b/Sources/WASI/WASI.swift index 631a3325..41035b8b 100644 --- a/Sources/WASI/WASI.swift +++ b/Sources/WASI/WASI.swift @@ -1824,7 +1824,13 @@ public class WASIBridgeToHost: WASI { oldFd: WASIAbi.Fd, oldPath: String, newFd: WASIAbi.Fd, newPath: String ) throws { - throw WASIAbi.Errno.ENOTSUP + guard case let .directory(oldDirEntry) = fdTable[oldFd] else { + throw WASIAbi.Errno.ENOTDIR + } + guard case let .directory(newDirEntry) = fdTable[newFd] else { + throw WASIAbi.Errno.ENOTDIR + } + try oldDirEntry.rename(from: oldPath, toDir: newDirEntry, to: newPath) } func path_symlink(oldPath: String, dirFd: WASIAbi.Fd, newPath: String) throws { diff --git a/Tests/WASITests/IntegrationTests.swift b/Tests/WASITests/IntegrationTests.swift index 6981fe0d..0ed174cc 100644 --- a/Tests/WASITests/IntegrationTests.swift +++ b/Tests/WASITests/IntegrationTests.swift @@ -110,8 +110,6 @@ final class IntegrationTests: XCTestCase { "WASI Rust tests": [ "path_link", "dir_fd_op_failures", - "path_rename_dir_trailing_slashes", - "path_rename", "pwrite-with-append", "poll_oneoff_stdio", "overwrite_preopen",