Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions Sources/SystemExtras/FileAtOperations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<CInterop.PlatformChar>,
to newDirFd: FileDescriptor,
at newPath: UnsafePointer<CInterop.PlatformChar>
) throws {
try _rename(at: oldPath, to: newDirFd, at: newPath).get()
}

@usableFromInline
internal func _rename(
at oldPath: UnsafePointer<CInterop.PlatformChar>,
to newDirFd: FileDescriptor,
at newPath: UnsafePointer<CInterop.PlatformChar>
) -> Result<(), Errno> {
#if os(Windows)
Copy link

Copilot AI Oct 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Windows error code ERROR_NOT_SUPPORTED should be documented or explained why this specific error is chosen over other potential Windows error codes for unsupported operations.

Suggested change
#if os(Windows)
#if os(Windows)
// The Windows API does not provide a direct equivalent to `renameat`.
// ERROR_NOT_SUPPORTED is used to indicate that this operation is not supported on Windows.

Copilot uses AI. Check for mistakes.

return .failure(Errno(rawValue: ERROR_NOT_SUPPORTED))
#else
return nothingOrErrno(retryOnInterrupt: false) {
system_renameat(self.rawValue, oldPath, newDirFd.rawValue, newPath)
}
#endif
}
}
8 changes: 8 additions & 0 deletions Sources/SystemExtras/Syscalls.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ internal func system_unlinkat(
return unlinkat(fd, path, flags)
}

// renameat
internal func system_renameat(
_ oldfd: Int32, _ oldpath: UnsafePointer<CInterop.PlatformChar>,
_ newfd: Int32, _ newpath: UnsafePointer<CInterop.PlatformChar>
) -> CInt {
return renameat(oldfd, oldpath, newfd, newpath)
}

// ftruncate
internal func system_ftruncate(_ fd: Int32, _ size: off_t) -> CInt {
return ftruncate(fd, size)
Expand Down
1 change: 1 addition & 0 deletions Sources/WASI/FileSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Result<ReaddirElement, any Error>>
func attributes(path: String, symlinkFollow: Bool) throws -> WASIAbi.Filestat
func setFilestatTimes(
Expand Down
38 changes: 38 additions & 0 deletions Sources/WASI/Platform/Directory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +144 to +146
Copy link

Copilot AI Oct 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Consider extracting the slash re-appending logic into a helper function to reduce code duplication and improve readability.

Copilot uses AI. Check for mistakes.


try WASIAbi.Errno.translatingPlatformErrno {
try sourceDir.rename(
at: FilePath(finalSourceBasename),
to: destDir,
at: FilePath(finalDestBasename)
)
}
#endif
}

func readEntries(
cookie: WASIAbi.DirCookie
) throws -> AnyIterator<Result<ReaddirElement, any Error>> {
Expand Down
16 changes: 16 additions & 0 deletions Sources/WASI/Platform/SandboxPrimitives/OpenParent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion Sources/WASI/WASI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 0 additions & 2 deletions Tests/WASITests/IntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down