diff --git a/Sources/NIOFS/FileSystem.swift b/Sources/NIOFS/FileSystem.swift index ef54426b037..ea724cadb02 100644 --- a/Sources/NIOFS/FileSystem.swift +++ b/Sources/NIOFS/FileSystem.swift @@ -325,10 +325,16 @@ public struct FileSystem: Sendable, FileSystemProtocol { /// /// This function is platform dependent. On Darwin the `copyfile(2)` system call is used and /// items are cloned where possible. On Linux the `sendfile(2)` system call is used. + /// + /// When `overwriting` is `true`, regular files are atomically replaced using `COPYFILE_UNLINK` + /// on Darwin or a temporary file followed by `renameat2(2)` on Linux. Symbolic links are + /// atomically replaced using a temporary symlink followed by `renamex_np(2)` on Darwin or + /// `renameat2(2)` on Linux. public func copyItem( at sourcePath: NIOFilePath, to destinationPath: NIOFilePath, strategy copyStrategy: CopyStrategy, + overwriting: Bool = false, shouldProceedAfterError: @escaping @Sendable ( _ source: DirectoryEntry, @@ -355,10 +361,18 @@ public struct FileSystem: Sendable, FileSystemProtocol { if await shouldCopyItem(.init(path: sourcePath, type: info.type)!, destinationPath) { switch info.type { case .regular: - try await self.copyRegularFile(from: sourcePath.underlying, to: destinationPath.underlying) + try await self.copyRegularFile( + from: sourcePath.underlying, + to: destinationPath.underlying, + overwriting: overwriting + ) case .symlink: - try await self.copySymbolicLink(from: sourcePath.underlying, to: destinationPath.underlying) + try await self.copySymbolicLink( + from: sourcePath.underlying, + to: destinationPath.underlying, + overwriting: overwriting + ) case .directory: try await self.copyDirectory( @@ -1248,16 +1262,22 @@ extension FileSystem { private func copyRegularFile( from sourcePath: FilePath, - to destinationPath: FilePath + to destinationPath: FilePath, + overwriting: Bool = false ) async throws { try await self.threadPool.runIfActive { - try self._copyRegularFile(from: sourcePath, to: destinationPath).get() + try self._copyRegularFile( + from: sourcePath, + to: destinationPath, + overwriting: overwriting + ).get() } } private func _copyRegularFile( from sourcePath: FilePath, - to destinationPath: FilePath + to destinationPath: FilePath, + overwriting: Bool = false ) -> Result { func makeOnUnavailableError( path: FilePath, @@ -1275,7 +1295,10 @@ extension FileSystem { // COPYFILE_CLONE clones the file if possible and will fallback to doing a copy. // COPYFILE_ALL is shorthand for: // COPYFILE_STAT | COPYFILE_ACL | COPYFILE_XATTR | COPYFILE_DATA - let flags = copyfile_flags_t(COPYFILE_CLONE) | copyfile_flags_t(COPYFILE_ALL) + var flags = copyfile_flags_t(COPYFILE_CLONE) | copyfile_flags_t(COPYFILE_ALL) + if overwriting { + flags |= copyfile_flags_t(COPYFILE_UNLINK) + } return Libc.copyfile( from: sourcePath, to: destinationPath, @@ -1291,6 +1314,167 @@ extension FileSystem { } #elseif canImport(Glibc) || canImport(Musl) || canImport(Bionic) + if !overwriting { + func openDestination( + _ path: FilePath, + options: OpenOptions.Write + ) -> Result { + self._openFile(forWritingAt: path, options: options).mapError { + FileSystemError( + message: "Can't copy '\(sourcePath)' as '\(path)' couldn't be opened.", + wrapping: $0 + ) + } + } + + return self._copyRegularFileOnLinux( + from: sourcePath, + to: destinationPath, + openDestination: openDestination + ) + } + + // on Linux platforms we want to imitate overwriting by copying the source file into + // a temporary destination and then atomically renaming it, using the renameat2(2) system call + guard let filenameComponent = destinationPath.lastComponent else { + return .failure( + FileSystemError( + code: .invalidArgument, + message: "Can't copy to '\(destinationPath)', path has no filename component.", + cause: nil, + location: .here() + ) + ) + } + let filename = filenameComponent.string + let destinationParentDirectory = destinationPath.removingLastComponent() + + let destinationParentDirectoryHandle: DirectoryFileHandle + switch self._openDirectory( + at: destinationParentDirectory, + // not following symlinks here to prevent TOCTOU attacks where the parent directory + // is replaced with a symlink pointing to an attacker-controlled location + options: OpenOptions.Directory(followSymbolicLinks: false) + ) { + case let .success(handle): + destinationParentDirectoryHandle = handle + case let .failure(error): + return .failure( + FileSystemError( + message: "Can't copy '\(sourcePath)' to '\(destinationPath)', parent directory couldn't be opened.", + wrapping: error + ) + ) + } + + defer { + _ = destinationParentDirectoryHandle + .fileHandle + .systemFileHandle + .sendableView + ._close(materialize: true) + } + + guard + let destinationParentDirectoryFD = destinationParentDirectoryHandle + .fileHandle + .systemFileHandle + .sendableView + .descriptorIfAvailable() + else { + let error = FileSystemError( + code: .closed, + message: + "Can't copy '\(sourcePath)' to '\(destinationPath)', parent directory descriptor unavailable.", + cause: nil, + location: .here() + ) + return .failure(error) + } + + func openDestination( + _ path: FilePath, + options: OpenOptions.Write + ) -> Result { + destinationParentDirectoryFD.open( + atPath: path, + mode: .writeOnly, + options: options.descriptorOptions, + permissions: options.permissionsForRegularFile + ).mapError { errno in + let openError = FileSystemError.open( + "openat", + error: errno, + path: path, + location: .here() + ) + return FileSystemError( + message: "Can't copy '\(sourcePath)' as '\(path)' couldn't be opened.", + wrapping: openError + ) + }.map { fd in + let handle = SystemFileHandle( + takingOwnershipOf: fd, + path: path, + threadPool: self.threadPool + ) + return WriteFileHandle(wrapping: handle) + } + } + + let temporaryFilePath = FilePath(".tmp-" + String(randomAlphaNumericOfLength: 6)) + let copyResult = self._copyRegularFileOnLinux( + from: sourcePath, + to: temporaryFilePath, + openDestination: openDestination + ) + + guard case .success = copyResult else { + _ = Syscall.unlinkat(path: temporaryFilePath, relativeTo: destinationParentDirectoryFD) + return copyResult + } + + let destinationFilePath = FilePath(filename) + switch Syscall.rename( + from: temporaryFilePath, + relativeTo: destinationParentDirectoryFD, + to: destinationFilePath, + relativeTo: destinationParentDirectoryFD, + flags: [] + ) { + case .failure(let errno): + _ = Syscall.unlinkat(path: temporaryFilePath, relativeTo: destinationParentDirectoryFD) + let error = FileSystemError.rename( + "renameat2", + errno: errno, + oldName: temporaryFilePath, + newName: destinationFilePath, + location: .here() + ) + return .failure(error) + case .success: + return .success(()) + } + #endif + } + + #if canImport(Glibc) || canImport(Musl) || canImport(Bionic) + private func _copyRegularFileOnLinux( + from sourcePath: FilePath, + to destinationPath: FilePath, + openDestination: (FilePath, OpenOptions.Write) -> Result + ) -> Result { + func makeOnUnavailableError( + path: FilePath, + location: FileSystemError.SourceLocation + ) -> FileSystemError { + FileSystemError( + code: .closed, + message: "Can't copy '\(sourcePath)' to '\(destinationPath)', '\(path)' is closed.", + cause: nil, + location: location + ) + } let openSourceResult = self._openFile( forReadingAt: sourcePath, @@ -1330,18 +1514,8 @@ extension FileSystem { ) ) - let openDestinationResult = self._openFile( - forWritingAt: destinationPath, - options: options - ).mapError { - FileSystemError( - message: "Can't copy '\(sourcePath)' as '\(destinationPath)' couldn't be opened.", - wrapping: $0 - ) - } - let destination: WriteFileHandle - switch openDestinationResult { + switch openDestination(destinationPath, options) { case let .success(handle): destination = handle case let .failure(error): @@ -1387,25 +1561,162 @@ extension FileSystem { let closeResult = destination.fileHandle.systemFileHandle.sendableView._close(materialize: true) return copyResult.flatMap { closeResult } - #endif } + #endif private func copySymbolicLink( from sourcePath: FilePath, - to destinationPath: FilePath + to destinationPath: FilePath, + overwriting: Bool = false ) async throws { try await self.threadPool.runIfActive { - try self._copySymbolicLink(from: sourcePath, to: destinationPath).get() + try self._copySymbolicLink( + from: sourcePath, + to: destinationPath, + overwriting: overwriting + ).get() } } private func _copySymbolicLink( from sourcePath: FilePath, - to destinationPath: FilePath + to destinationPath: FilePath, + overwriting: Bool ) -> Result { - self._destinationOfSymbolicLink(at: sourcePath).flatMap { linkDestination in - self._createSymbolicLink(at: destinationPath, withDestination: linkDestination) + if !overwriting { + return self._destinationOfSymbolicLink(at: sourcePath).flatMap { linkDestination in + self._createSymbolicLink(at: destinationPath, withDestination: linkDestination) + } + } + + // there is no atomic symlink overwriting copy on either Darwin or Linux platforms + // so we copy the symlink into a temporary symlink using `symlinkat` system call and then + // rename it into the destination symlink using the `renameatx_np(2)` on Darwin and + // `renameat2(2)` on non-Darwin platforms + guard let filenameComponent = destinationPath.lastComponent else { + return .failure( + FileSystemError( + code: .invalidArgument, + message: "Can't copy to '\(destinationPath)', path has no filename component.", + cause: nil, + location: .here() + ) + ) } + + let filename = filenameComponent.string + let destinationParentDirectory = destinationPath.removingLastComponent() + + let destinationParentDirectoryHandle: DirectoryFileHandle + switch self._openDirectory( + at: destinationParentDirectory, + // not following symlinks here to prevent TOCTOU attacks where the parent directory + // is replaced with a symlink pointing to an attacker-controlled location + options: OpenOptions.Directory(followSymbolicLinks: false) + ) { + case let .success(handle): + destinationParentDirectoryHandle = handle + case let .failure(error): + return .failure( + FileSystemError( + message: "Can't copy '\(sourcePath)' to '\(destinationPath)', parent directory couldn't be opened.", + wrapping: error + ) + ) + } + + defer { + _ = destinationParentDirectoryHandle + .fileHandle + .systemFileHandle + .sendableView + ._close(materialize: true) + } + + guard + let destinationParentDirectoryFD = destinationParentDirectoryHandle + .fileHandle + .systemFileHandle + .sendableView + .descriptorIfAvailable() + else { + let error = FileSystemError( + code: .closed, + message: + "Can't copy '\(sourcePath)' to '\(destinationPath)', parent directory descriptor unavailable.", + cause: nil, + location: .here() + ) + return .failure(error) + } + + let linkTarget: FilePath + switch self._destinationOfSymbolicLink(at: sourcePath) { + case let .success(target): + linkTarget = target + case let .failure(error): + return .failure(error) + } + + let temporarySymlinkPath = FilePath(".tmp-link-" + String(randomAlphaNumericOfLength: 6)) + if case let .failure(errno) = Syscall.symlinkat( + to: linkTarget, + in: destinationParentDirectoryFD, + from: temporarySymlinkPath + ) { + let error = FileSystemError.symlink( + errno: errno, + link: temporarySymlinkPath, + target: linkTarget, + location: .here() + ) + return .failure(error) + } + + let destinationFilePath = FilePath(filename) + #if canImport(Darwin) + switch Syscall.rename( + from: temporarySymlinkPath, + relativeTo: destinationParentDirectoryFD, + to: destinationFilePath, + relativeTo: destinationParentDirectoryFD, + options: [] + ) { + case .success: + return .success(()) + case .failure(let errno): + _ = Syscall.unlinkat(path: temporarySymlinkPath, relativeTo: destinationParentDirectoryFD) + let error = FileSystemError.rename( + "renameatx_np", + errno: errno, + oldName: temporarySymlinkPath, + newName: destinationFilePath, + location: .here() + ) + return .failure(error) + } + #elseif canImport(Glibc) || canImport(Musl) || canImport(Bionic) + switch Syscall.rename( + from: temporarySymlinkPath, + relativeTo: destinationParentDirectoryFD, + to: destinationFilePath, + relativeTo: destinationParentDirectoryFD, + flags: [] + ) { + case .success: + return .success(()) + case .failure(let errno): + _ = Syscall.unlinkat(path: temporarySymlinkPath, relativeTo: destinationParentDirectoryFD) + let error = FileSystemError.rename( + "renameat2", + errno: errno, + oldName: temporarySymlinkPath, + newName: destinationFilePath, + location: .here() + ) + return .failure(error) + } + #endif } @_spi(Testing) diff --git a/Sources/NIOFS/FileSystemProtocol.swift b/Sources/NIOFS/FileSystemProtocol.swift index 8fb4b283abe..f54dad7c65b 100644 --- a/Sources/NIOFS/FileSystemProtocol.swift +++ b/Sources/NIOFS/FileSystemProtocol.swift @@ -175,7 +175,7 @@ public protocol FileSystemProtocol: Sendable { /// The following error codes may be thrown: /// - ``FileSystemError/Code-swift.struct/notFound`` if the item at `sourcePath` does not exist, /// - ``FileSystemError/Code-swift.struct/invalidArgument`` if an item at `destinationPath` - /// exists prior to the copy or its parent directory does not exist. + /// exists prior to the copy (when `overwriting` is `false`) or its parent directory does not exist. /// /// Note that other errors may also be thrown. /// @@ -186,6 +186,7 @@ public protocol FileSystemProtocol: Sendable { /// - sourcePath: The path to the item to copy. /// - destinationPath: The path at which to place the copy. /// - copyStrategy: How to deal with concurrent aspects of the copy, only relevant to directories. + /// - overwriting: Whether to overwrite an existing file or symlink at `destinationPath`. /// - shouldProceedAfterError: A closure which is executed to determine whether to continue /// copying files if an error is encountered during the operation. See Errors section for full details. /// - shouldCopyItem: A closure which is executed before each copy to determine whether each @@ -195,7 +196,7 @@ public protocol FileSystemProtocol: Sendable { /// /// No errors should be throw by implementors without first calling `shouldProceedAfterError`, /// if that returns without throwing this is taken as permission to continue and the error is swallowed. - /// If instead the closure throws then ``copyItem(at:to:strategy:shouldProceedAfterError:shouldCopyItem:)`` + /// If instead the closure throws then ``copyItem(at:to:strategy:overwriting:shouldProceedAfterError:shouldCopyItem:)`` /// will throw and copying will stop, though the precise semantics of this can depend on the `strategy`. /// /// if using ``CopyStrategy/parallel(maxDescriptors:)`` @@ -236,6 +237,7 @@ public protocol FileSystemProtocol: Sendable { at sourcePath: NIOFilePath, to destinationPath: NIOFilePath, strategy copyStrategy: CopyStrategy, + overwriting: Bool, shouldProceedAfterError: @escaping @Sendable ( _ source: DirectoryEntry, @@ -472,7 +474,7 @@ extension FileSystemProtocol { /// /// Note that other errors may also be thrown. If any error is encountered during the copy /// then the copy is aborted. You can modify the behaviour with the `shouldProceedAfterError` - /// parameter of ``FileSystemProtocol/copyItem(at:to:strategy:shouldProceedAfterError:shouldCopyItem:)``. + /// parameter of ``FileSystemProtocol/copyItem(at:to:strategy:overwriting:shouldProceedAfterError:shouldCopyItem:)``. /// /// If the file at `sourcePath` is a symbolic link then only the link is copied to the new path. /// @@ -485,11 +487,18 @@ extension FileSystemProtocol { to destinationPath: NIOFilePath, strategy copyStrategy: CopyStrategy = .platformDefault ) async throws { - try await self.copyItem(at: sourcePath, to: destinationPath, strategy: copyStrategy) { path, error in - throw error - } shouldCopyItem: { source, destination in - true - } + try await self.copyItem( + at: sourcePath, + to: destinationPath, + strategy: copyStrategy, + overwriting: false, + shouldProceedAfterError: { path, error in + throw error + }, + shouldCopyItem: { source, destination in + true + } + ) } /// Copies the item at the specified path to a new location. @@ -516,7 +525,7 @@ extension FileSystemProtocol { /// /// This overload uses ``CopyStrategy/platformDefault`` which is likely to result in multiple concurrency domains being used /// in the event of copying a directory. - /// See the detailed description on ``copyItem(at:to:strategy:shouldProceedAfterError:shouldCopyItem:)`` + /// See the detailed description on ``copyItem(at:to:strategy:overwriting:shouldProceedAfterError:shouldCopyItem:)`` /// for the implications of this with respect to the `shouldProceedAfterError` and `shouldCopyItem` callbacks public func copyItem( at sourcePath: NIOFilePath, @@ -536,6 +545,7 @@ extension FileSystemProtocol { at: sourcePath, to: destinationPath, strategy: .platformDefault, + overwriting: false, shouldProceedAfterError: shouldProceedAfterError, shouldCopyItem: shouldCopyItem ) diff --git a/Sources/NIOFS/IOStrategy.swift b/Sources/NIOFS/IOStrategy.swift index 5bf673e3125..0ca64414276 100644 --- a/Sources/NIOFS/IOStrategy.swift +++ b/Sources/NIOFS/IOStrategy.swift @@ -56,7 +56,7 @@ enum IOStrategy: Hashable, Sendable { } /// How to perform copies. Currently only relevant to directory level copies when using -/// ``FileSystemProtocol/copyItem(at:to:strategy:shouldProceedAfterError:shouldCopyItem:)`` or other +/// ``FileSystemProtocol/copyItem(at:to:strategy:overwriting:shouldProceedAfterError:shouldCopyItem:)`` or other /// overloads that use the default behaviour. public struct CopyStrategy: Hashable, Sendable { internal let wrapped: IOStrategy diff --git a/Sources/NIOFS/Internal/System Calls/Syscall.swift b/Sources/NIOFS/Internal/System Calls/Syscall.swift index cac003fa63e..2904ecf4369 100644 --- a/Sources/NIOFS/Internal/System Calls/Syscall.swift +++ b/Sources/NIOFS/Internal/System Calls/Syscall.swift @@ -106,6 +106,29 @@ public enum Syscall: Sendable { Self(rawValue: UInt32(bitPattern: RENAME_SWAP)) } } + + @_spi(Testing) + public static func rename( + from old: FilePath, + relativeTo oldFD: FileDescriptor, + to new: FilePath, + relativeTo newFD: FileDescriptor, + options: RenameOptions + ) -> Result { + nothingOrErrno(retryOnInterrupt: false) { + old.withPlatformString { oldPath in + new.withPlatformString { newPath in + system_renameatx_np( + oldFD.rawValue, + oldPath, + newFD.rawValue, + newPath, + options.rawValue + ) + } + } + } + } #endif #if canImport(Glibc) || canImport(Musl) || canImport(Bionic) @@ -219,6 +242,19 @@ public enum Syscall: Sendable { } } + @_spi(Testing) + public static func unlinkat( + path: FilePath, + relativeTo directoryDescriptor: FileDescriptor, + flags: CInt = 0 + ) -> Result { + nothingOrErrno(retryOnInterrupt: false) { + path.withPlatformString { ptr in + system_unlinkat(directoryDescriptor.rawValue, ptr, flags) + } + } + } + @_spi(Testing) public static func symlink( to destination: FilePath, @@ -233,6 +269,21 @@ public enum Syscall: Sendable { } } + @_spi(Testing) + public static func symlinkat( + to destination: FilePath, + in directoryDescriptor: FileDescriptor, + from source: FilePath + ) -> Result { + nothingOrErrno(retryOnInterrupt: false) { + source.withPlatformString { src in + destination.withPlatformString { dst in + system_symlinkat(dst, directoryDescriptor.rawValue, src) + } + } + } + } + @_spi(Testing) public static func readlink(at path: FilePath) -> Result { do { diff --git a/Sources/NIOFS/Internal/System Calls/Syscalls.swift b/Sources/NIOFS/Internal/System Calls/Syscalls.swift index 63621c21c20..dae4f334841 100644 --- a/Sources/NIOFS/Internal/System Calls/Syscalls.swift +++ b/Sources/NIOFS/Internal/System Calls/Syscalls.swift @@ -149,6 +149,20 @@ internal func system_symlink( return symlink(destination, source) } +/// symlinkat(2): Make symbolic link to a file relative to directory file descriptor +internal func system_symlinkat( + _ destination: UnsafePointer, + _ dirfd: FileDescriptor.RawValue, + _ source: UnsafePointer +) -> CInt { + #if ENABLE_MOCKING + if mockingEnabled { + return mock(destination, dirfd, source) + } + #endif + return symlinkat(destination, dirfd, source) +} + /// readlink(2): Read value of a symolic link internal func system_readlink( _ path: UnsafePointer, @@ -271,6 +285,21 @@ internal func system_renamex_np( #endif return renamex_np(old, new, flags) } + +internal func system_renameatx_np( + _ oldFD: FileDescriptor.RawValue, + _ old: UnsafePointer, + _ newFD: FileDescriptor.RawValue, + _ new: UnsafePointer, + _ flags: CUnsignedInt +) -> CInt { + #if ENABLE_MOCKING + if mockingEnabled { + return mock(oldFD, old, newFD, new, flags) + } + #endif + return renameatx_np(oldFD, old, newFD, new, flags) +} #endif #if canImport(Glibc) || canImport(Musl) || canImport(Android) @@ -333,6 +362,20 @@ internal func system_unlink( return unlink(path) } +/// unlinkat(2): Remove a directory entry relative to a directory file descriptor. +internal func system_unlinkat( + _ fd: FileDescriptor.RawValue, + _ path: UnsafePointer, + _ flags: CInt +) -> CInt { + #if ENABLE_MOCKING + if mockingEnabled { + return mock(fd, path, flags) + } + #endif + return unlinkat(fd, path, flags) +} + #if canImport(Glibc) || canImport(Musl) || canImport(Android) /// sendfile(2): Transfer data between descriptors internal func system_sendfile( diff --git a/Sources/_NIOFileSystem/Docs.docc/Extensions/FileSystemProtocol.md b/Sources/_NIOFileSystem/Docs.docc/Extensions/FileSystemProtocol.md index c7f2d4b075e..ef27efc0e6b 100644 --- a/Sources/_NIOFileSystem/Docs.docc/Extensions/FileSystemProtocol.md +++ b/Sources/_NIOFileSystem/Docs.docc/Extensions/FileSystemProtocol.md @@ -33,7 +33,7 @@ closing it to avoid leaking resources. ### Managing files -- ``copyItem(at:to:strategy:shouldProceedAfterError:shouldCopyItem:)`` +- ``copyItem(at:to:strategy:overwriting:shouldProceedAfterError:shouldCopyItem:)`` - ``removeItem(at:)`` - ``moveItem(at:to:)`` - ``replaceItem(at:withItemAt:)`` diff --git a/Sources/_NIOFileSystem/FileSystem.swift b/Sources/_NIOFileSystem/FileSystem.swift index d4622f2f1dd..aa40e01f482 100644 --- a/Sources/_NIOFileSystem/FileSystem.swift +++ b/Sources/_NIOFileSystem/FileSystem.swift @@ -306,7 +306,7 @@ public struct FileSystem: Sendable, FileSystemProtocol { // MARK: - File copying, removal, and moving - /// See ``FileSystemProtocol/copyItem(at:to:shouldProceedAfterError:shouldCopyFile:)`` + /// See ``FileSystemProtocol/copyItem(at:to:strategy:overwriting:shouldProceedAfterError:shouldCopyItem:)`` /// /// The item to be copied must be a: /// - regular file, @@ -325,10 +325,16 @@ public struct FileSystem: Sendable, FileSystemProtocol { /// /// This function is platform dependent. On Darwin the `copyfile(2)` system call is used and /// items are cloned where possible. On Linux the `sendfile(2)` system call is used. + /// + /// When `overwriting` is `true`, regular files are atomically replaced using `COPYFILE_UNLINK` + /// on Darwin or a temporary file followed by `renameat2(2)` on Linux. Symbolic links are + /// atomically replaced using a temporary symlink followed by `renamex_np(2)` on Darwin or + /// `renameat2(2)` on Linux. public func copyItem( at sourcePath: FilePath, to destinationPath: FilePath, strategy copyStrategy: CopyStrategy, + overwriting: Bool = false, shouldProceedAfterError: @escaping @Sendable ( _ source: DirectoryEntry, @@ -355,10 +361,10 @@ public struct FileSystem: Sendable, FileSystemProtocol { if await shouldCopyItem(.init(path: sourcePath, type: info.type)!, destinationPath) { switch info.type { case .regular: - try await self.copyRegularFile(from: sourcePath, to: destinationPath) + try await self.copyRegularFile(from: sourcePath, to: destinationPath, overwriting: overwriting) case .symlink: - try await self.copySymbolicLink(from: sourcePath, to: destinationPath) + try await self.copySymbolicLink(from: sourcePath, to: destinationPath, overwriting: overwriting) case .directory: try await self.copyDirectory( @@ -1260,16 +1266,22 @@ extension FileSystem { private func copyRegularFile( from sourcePath: FilePath, - to destinationPath: FilePath + to destinationPath: FilePath, + overwriting: Bool = false ) async throws { try await self.threadPool.runIfActive { - try self._copyRegularFile(from: sourcePath, to: destinationPath).get() + try self._copyRegularFile( + from: sourcePath, + to: destinationPath, + overwriting: overwriting + ).get() } } private func _copyRegularFile( from sourcePath: FilePath, - to destinationPath: FilePath + to destinationPath: FilePath, + overwriting: Bool ) -> Result { func makeOnUnavailableError( path: FilePath, @@ -1287,7 +1299,11 @@ extension FileSystem { // COPYFILE_CLONE clones the file if possible and will fallback to doing a copy. // COPYFILE_ALL is shorthand for: // COPYFILE_STAT | COPYFILE_ACL | COPYFILE_XATTR | COPYFILE_DATA - let flags = copyfile_flags_t(COPYFILE_CLONE) | copyfile_flags_t(COPYFILE_ALL) + var flags = copyfile_flags_t(COPYFILE_CLONE) | copyfile_flags_t(COPYFILE_ALL) + if overwriting { + flags |= copyfile_flags_t(COPYFILE_UNLINK) + } + return Libc.copyfile( from: sourcePath, to: destinationPath, @@ -1303,6 +1319,167 @@ extension FileSystem { } #elseif canImport(Glibc) || canImport(Musl) || canImport(Bionic) + if !overwriting { + func openDestination( + _ path: FilePath, + options: OpenOptions.Write + ) -> Result { + self._openFile(forWritingAt: path, options: options).mapError { + FileSystemError( + message: "Can't copy '\(sourcePath)' as '\(path)' couldn't be opened.", + wrapping: $0 + ) + } + } + + return self._copyRegularFileOnLinux( + from: sourcePath, + to: destinationPath, + openDestination: openDestination + ) + } + + // on Linux platforms we want to imitate overwriting by copying the source file into + // a temporary destination and then atomically renaming it, using the renameat2(2) system call + guard let filenameComponent = destinationPath.lastComponent else { + return .failure( + FileSystemError( + code: .invalidArgument, + message: "Can't copy to '\(destinationPath)', path has no filename component.", + cause: nil, + location: .here() + ) + ) + } + let filename = filenameComponent.string + let destinationParentDirectory = destinationPath.removingLastComponent() + + let destinationParentDirectoryHandle: DirectoryFileHandle + switch self._openDirectory( + at: destinationParentDirectory, + // not following symlinks here to prevent TOCTOU attacks where the parent directory + // is replaced with a symlink pointing to an attacker-controlled location + options: OpenOptions.Directory(followSymbolicLinks: false) + ) { + case let .success(handle): + destinationParentDirectoryHandle = handle + case let .failure(error): + return .failure( + FileSystemError( + message: "Can't copy '\(sourcePath)' to '\(destinationPath)', parent directory couldn't be opened.", + wrapping: error + ) + ) + } + + defer { + _ = destinationParentDirectoryHandle + .fileHandle + .systemFileHandle + .sendableView + ._close(materialize: true) + } + + guard + let destinationParentDirectoryFD = destinationParentDirectoryHandle + .fileHandle + .systemFileHandle + .sendableView + .descriptorIfAvailable() + else { + let error = FileSystemError( + code: .closed, + message: + "Can't copy '\(sourcePath)' to '\(destinationPath)', parent directory descriptor unavailable.", + cause: nil, + location: .here() + ) + return .failure(error) + } + + func openDestination( + _ path: FilePath, + options: OpenOptions.Write + ) -> Result { + destinationParentDirectoryFD.open( + atPath: path, + mode: .writeOnly, + options: options.descriptorOptions, + permissions: options.permissionsForRegularFile + ).mapError { errno in + let openError = FileSystemError.open( + "openat", + error: errno, + path: path, + location: .here() + ) + return FileSystemError( + message: "Can't copy '\(sourcePath)' as '\(path)' couldn't be opened.", + wrapping: openError + ) + }.map { fd in + let handle = SystemFileHandle( + takingOwnershipOf: fd, + path: path, + threadPool: self.threadPool + ) + return WriteFileHandle(wrapping: handle) + } + } + + let temporaryFilePath = FilePath(".tmp-" + String(randomAlphaNumericOfLength: 6)) + let copyResult = self._copyRegularFileOnLinux( + from: sourcePath, + to: temporaryFilePath, + openDestination: openDestination + ) + + guard case .success = copyResult else { + _ = Syscall.unlinkat(path: temporaryFilePath, relativeTo: destinationParentDirectoryFD) + return copyResult + } + + let destinationFilePath = FilePath(filename) + switch Syscall.rename( + from: temporaryFilePath, + relativeTo: destinationParentDirectoryFD, + to: destinationFilePath, + relativeTo: destinationParentDirectoryFD, + flags: [] + ) { + case .failure(let errno): + _ = Syscall.unlinkat(path: temporaryFilePath, relativeTo: destinationParentDirectoryFD) + let error = FileSystemError.rename( + "renameat2", + errno: errno, + oldName: temporaryFilePath, + newName: destinationFilePath, + location: .here() + ) + return .failure(error) + case .success: + return .success(()) + } + #endif + } + + #if canImport(Glibc) || canImport(Musl) || canImport(Bionic) + private func _copyRegularFileOnLinux( + from sourcePath: FilePath, + to destinationPath: FilePath, + openDestination: (FilePath, OpenOptions.Write) -> Result + ) -> Result { + func makeOnUnavailableError( + path: FilePath, + location: FileSystemError.SourceLocation + ) -> FileSystemError { + FileSystemError( + code: .closed, + message: "Can't copy '\(sourcePath)' to '\(destinationPath)', '\(path)' is closed.", + cause: nil, + location: location + ) + } let openSourceResult = self._openFile( forReadingAt: sourcePath, @@ -1342,18 +1519,8 @@ extension FileSystem { ) ) - let openDestinationResult = self._openFile( - forWritingAt: destinationPath, - options: options - ).mapError { - FileSystemError( - message: "Can't copy '\(sourcePath)' as '\(destinationPath)' couldn't be opened.", - wrapping: $0 - ) - } - let destination: WriteFileHandle - switch openDestinationResult { + switch openDestination(destinationPath, options) { case let .success(handle): destination = handle case let .failure(error): @@ -1399,25 +1566,158 @@ extension FileSystem { let closeResult = destination.fileHandle.systemFileHandle.sendableView._close(materialize: true) return copyResult.flatMap { closeResult } - #endif } + #endif private func copySymbolicLink( from sourcePath: FilePath, - to destinationPath: FilePath + to destinationPath: FilePath, + overwriting: Bool = false ) async throws { try await self.threadPool.runIfActive { - try self._copySymbolicLink(from: sourcePath, to: destinationPath).get() + try self._copySymbolicLink(from: sourcePath, to: destinationPath, overwriting: overwriting).get() } } private func _copySymbolicLink( from sourcePath: FilePath, - to destinationPath: FilePath + to destinationPath: FilePath, + overwriting: Bool ) -> Result { - self._destinationOfSymbolicLink(at: sourcePath).flatMap { linkDestination in - self._createSymbolicLink(at: destinationPath, withDestination: linkDestination) + if !overwriting { + return self._destinationOfSymbolicLink(at: sourcePath).flatMap { linkDestination in + self._createSymbolicLink(at: destinationPath, withDestination: linkDestination) + } + } + + // there is no atomic symlink overwriting copy on either Darwin or Linux platforms + // so we copy the symlink into a temporary symlink using `symlinkat` system call and then + // rename it into the destination symlink using the `renameatx_np(2)` on Darwin and + // `renameat2(2)` on non-Darwin platforms + guard let filenameComponent = destinationPath.lastComponent else { + return .failure( + FileSystemError( + code: .invalidArgument, + message: "Can't copy to '\(destinationPath)', path has no filename component.", + cause: nil, + location: .here() + ) + ) + } + + let filename = filenameComponent.string + let destinationParentDirectory = destinationPath.removingLastComponent() + + let destinationParentDirectoryHandle: DirectoryFileHandle + switch self._openDirectory( + at: destinationParentDirectory, + // not following symlinks here to prevent TOCTOU attacks where the parent directory + // is replaced with a symlink pointing to an attacker-controlled location + options: OpenOptions.Directory(followSymbolicLinks: false) + ) { + case let .success(handle): + destinationParentDirectoryHandle = handle + case let .failure(error): + return .failure( + FileSystemError( + message: "Can't copy '\(sourcePath)' to '\(destinationPath)', parent directory couldn't be opened.", + wrapping: error + ) + ) + } + + defer { + _ = destinationParentDirectoryHandle + .fileHandle + .systemFileHandle + .sendableView + ._close(materialize: true) + } + + guard + let destinationParentDirectoryFD = destinationParentDirectoryHandle + .fileHandle + .systemFileHandle + .sendableView + .descriptorIfAvailable() + else { + let error = FileSystemError( + code: .closed, + message: + "Can't copy '\(sourcePath)' to '\(destinationPath)', parent directory descriptor unavailable.", + cause: nil, + location: .here() + ) + return .failure(error) + } + + let linkTarget: FilePath + switch self._destinationOfSymbolicLink(at: sourcePath) { + case let .success(target): + linkTarget = target + case let .failure(error): + return .failure(error) + } + + let temporarySymlinkPath = FilePath(".tmp-link-" + String(randomAlphaNumericOfLength: 6)) + if case let .failure(errno) = Syscall.symlinkat( + to: linkTarget, + in: destinationParentDirectoryFD, + from: temporarySymlinkPath + ) { + let error = FileSystemError.symlink( + errno: errno, + link: temporarySymlinkPath, + target: linkTarget, + location: .here() + ) + return .failure(error) } + + let destinationFilePath = FilePath(filename) + #if canImport(Darwin) + switch Syscall.rename( + from: temporarySymlinkPath, + relativeTo: destinationParentDirectoryFD, + to: destinationFilePath, + relativeTo: destinationParentDirectoryFD, + options: [] + ) { + case .success: + return .success(()) + case .failure(let errno): + _ = Syscall.unlinkat(path: temporarySymlinkPath, relativeTo: destinationParentDirectoryFD) + let error = FileSystemError.rename( + "renameatx_np", + errno: errno, + oldName: temporarySymlinkPath, + newName: destinationFilePath, + location: .here() + ) + return .failure(error) + } + #elseif canImport(Glibc) || canImport(Musl) || canImport(Bionic) + switch Syscall.rename( + from: temporarySymlinkPath, + relativeTo: destinationParentDirectoryFD, + to: destinationFilePath, + relativeTo: destinationParentDirectoryFD, + flags: [] + ) { + case .success: + return .success(()) + case .failure(let errno): + _ = Syscall.unlinkat(path: temporarySymlinkPath, relativeTo: destinationParentDirectoryFD) + let error = FileSystemError.rename( + "renameat2", + errno: errno, + oldName: temporarySymlinkPath, + newName: destinationFilePath, + location: .here() + ) + return .failure(error) + } + #endif } @_spi(Testing) diff --git a/Sources/_NIOFileSystem/FileSystemProtocol.swift b/Sources/_NIOFileSystem/FileSystemProtocol.swift index eeceb6a302a..c5efc20cfa8 100644 --- a/Sources/_NIOFileSystem/FileSystemProtocol.swift +++ b/Sources/_NIOFileSystem/FileSystemProtocol.swift @@ -175,7 +175,7 @@ public protocol FileSystemProtocol: Sendable { /// The following error codes may be thrown: /// - ``FileSystemError/Code-swift.struct/notFound`` if the item at `sourcePath` does not exist, /// - ``FileSystemError/Code-swift.struct/invalidArgument`` if an item at `destinationPath` - /// exists prior to the copy or its parent directory does not exist. + /// exists prior to the copy (when `overwriting` is `false`) or its parent directory does not exist. /// /// Note that other errors may also be thrown. /// @@ -186,6 +186,10 @@ public protocol FileSystemProtocol: Sendable { /// - sourcePath: The path to the item to copy. /// - destinationPath: The path at which to place the copy. /// - copyStrategy: How to deal with concurrent aspects of the copy, only relevant to directories. + /// - overwriting: Whether to overwrite an existing file at `destinationPath`. When `true`, + /// any existing regular file or symbolic link at the destination will be replaced atomically. + /// This parameter only affects regular files and symbolic links; directories are not overwritten. + /// Defaults to `false`. /// - shouldProceedAfterError: A closure which is executed to determine whether to continue /// copying files if an error is encountered during the operation. See Errors section for full details. /// - shouldCopyItem: A closure which is executed before each copy to determine whether each @@ -195,7 +199,7 @@ public protocol FileSystemProtocol: Sendable { /// /// No errors should be throw by implementors without first calling `shouldProceedAfterError`, /// if that returns without throwing this is taken as permission to continue and the error is swallowed. - /// If instead the closure throws then ``copyItem(at:to:strategy:shouldProceedAfterError:shouldCopyItem:)`` + /// If instead the closure throws then ``copyItem(at:to:strategy:overwriting:shouldProceedAfterError:shouldCopyItem:)`` /// will throw and copying will stop, though the precise semantics of this can depend on the `strategy`. /// /// if using ``CopyStrategy/parallel(maxDescriptors:)`` @@ -207,9 +211,9 @@ public protocol FileSystemProtocol: Sendable { /// /// The specific error thrown from copyItem is undefined, it does not have to be the same error thrown from /// `shouldProceedAfterError`. - /// In the event of any errors (ignored or otherwise) implementations are under no obbligation to + /// In the event of any errors (ignored or otherwise) implementations are under no obligation to /// attempt to 'tidy up' after themselves. The state of the file system within `destinationPath` - /// after an aborted copy should is undefined. + /// after an aborted copy is undefined. /// /// When calling `shouldProceedAfterError` implementations of this method /// MUST: @@ -236,6 +240,7 @@ public protocol FileSystemProtocol: Sendable { at sourcePath: FilePath, to destinationPath: FilePath, strategy copyStrategy: CopyStrategy, + overwriting: Bool, shouldProceedAfterError: @escaping @Sendable ( _ source: DirectoryEntry, @@ -475,7 +480,7 @@ extension FileSystemProtocol { /// /// Note that other errors may also be thrown. If any error is encountered during the copy /// then the copy is aborted. You can modify the behaviour with the `shouldProceedAfterError` - /// parameter of ``FileSystemProtocol/copyItem(at:to:strategy:shouldProceedAfterError:shouldCopyItem:)``. + /// parameter of ``FileSystemProtocol/copyItem(at:to:strategy:overwriting:shouldProceedAfterError:shouldCopyItem:)``. /// /// If the file at `sourcePath` is a symbolic link then only the link is copied to the new path. /// @@ -488,11 +493,18 @@ extension FileSystemProtocol { to destinationPath: FilePath, strategy copyStrategy: CopyStrategy = .platformDefault ) async throws { - try await self.copyItem(at: sourcePath, to: destinationPath, strategy: copyStrategy) { path, error in - throw error - } shouldCopyItem: { source, destination in - true - } + try await self.copyItem( + at: sourcePath, + to: destinationPath, + strategy: copyStrategy, + overwriting: false, + shouldProceedAfterError: { path, error in + throw error + }, + shouldCopyItem: { source, destination in + true + } + ) } /// Copies the item at the specified path to a new location. @@ -513,7 +525,7 @@ extension FileSystemProtocol { /// /// #### Backward Compatibility details /// - /// This is implemented in terms of ``copyItem(at:to:strategy:shouldProceedAfterError:shouldCopyItem:)`` + /// This is implemented in terms of ``copyItem(at:to:strategy:overwriting:shouldProceedAfterError:shouldCopyItem:)`` /// using ``CopyStrategy/sequential`` to avoid changing the concurrency semantics of the should callbacks /// /// - Parameters: @@ -543,6 +555,7 @@ extension FileSystemProtocol { at: sourcePath, to: destinationPath, strategy: .sequential, + overwriting: false, shouldProceedAfterError: shouldProceedAfterError, shouldCopyItem: { (source, destination) in await shouldCopyFile(source.path, destination) @@ -574,7 +587,7 @@ extension FileSystemProtocol { /// /// This overload uses ``CopyStrategy/platformDefault`` which is likely to result in multiple concurrency domains being used /// in the event of copying a directory. - /// See the detailed description on ``copyItem(at:to:strategy:shouldProceedAfterError:shouldCopyItem:)`` + /// See the detailed description on ``copyItem(at:to:strategy:overwriting:shouldProceedAfterError:shouldCopyItem:)`` /// for the implications of this with respect to the `shouldProceedAfterError` and `shouldCopyItem` callbacks public func copyItem( at sourcePath: FilePath, @@ -594,6 +607,7 @@ extension FileSystemProtocol { at: sourcePath, to: destinationPath, strategy: .platformDefault, + overwriting: false, shouldProceedAfterError: shouldProceedAfterError, shouldCopyItem: shouldCopyItem ) diff --git a/Sources/_NIOFileSystem/IOStrategy.swift b/Sources/_NIOFileSystem/IOStrategy.swift index 4c88476aa9b..70ae8ae80a8 100644 --- a/Sources/_NIOFileSystem/IOStrategy.swift +++ b/Sources/_NIOFileSystem/IOStrategy.swift @@ -56,7 +56,7 @@ enum IOStrategy: Hashable, Sendable { } /// How to perform copies. Currently only relevant to directory level copies when using -/// ``FileSystemProtocol/copyItem(at:to:strategy:shouldProceedAfterError:shouldCopyItem:)`` or other +/// ``FileSystemProtocol/copyItem(at:to:strategy:overwriting:shouldProceedAfterError:shouldCopyItem:)`` or other /// overloads that use the default behaviour. public struct CopyStrategy: Hashable, Sendable { internal let wrapped: IOStrategy diff --git a/Sources/_NIOFileSystem/Internal/System Calls/Syscall.swift b/Sources/_NIOFileSystem/Internal/System Calls/Syscall.swift index 5f6ed5cb7bf..ef1369a73a0 100644 --- a/Sources/_NIOFileSystem/Internal/System Calls/Syscall.swift +++ b/Sources/_NIOFileSystem/Internal/System Calls/Syscall.swift @@ -106,6 +106,29 @@ public enum Syscall: Sendable { Self(rawValue: UInt32(bitPattern: RENAME_SWAP)) } } + + @_spi(Testing) + public static func rename( + from old: FilePath, + relativeTo oldFD: FileDescriptor, + to new: FilePath, + relativeTo newFD: FileDescriptor, + options: RenameOptions + ) -> Result { + nothingOrErrno(retryOnInterrupt: false) { + old.withPlatformString { oldPath in + new.withPlatformString { newPath in + system_renameatx_np( + oldFD.rawValue, + oldPath, + newFD.rawValue, + newPath, + options.rawValue + ) + } + } + } + } #endif #if canImport(Glibc) || canImport(Musl) || canImport(Bionic) @@ -219,6 +242,19 @@ public enum Syscall: Sendable { } } + @_spi(Testing) + public static func unlinkat( + path: FilePath, + relativeTo directoryDescriptor: FileDescriptor, + flags: CInt = 0 + ) -> Result { + nothingOrErrno(retryOnInterrupt: false) { + path.withPlatformString { ptr in + system_unlinkat(directoryDescriptor.rawValue, ptr, flags) + } + } + } + @_spi(Testing) public static func symlink( to destination: FilePath, @@ -233,6 +269,21 @@ public enum Syscall: Sendable { } } + @_spi(Testing) + public static func symlinkat( + to destination: FilePath, + in directoryDescriptor: FileDescriptor, + from source: FilePath + ) -> Result { + nothingOrErrno(retryOnInterrupt: false) { + source.withPlatformString { src in + destination.withPlatformString { dst in + system_symlinkat(dst, directoryDescriptor.rawValue, src) + } + } + } + } + @_spi(Testing) public static func readlink(at path: FilePath) -> Result { do { diff --git a/Sources/_NIOFileSystem/Internal/System Calls/Syscalls.swift b/Sources/_NIOFileSystem/Internal/System Calls/Syscalls.swift index 63621c21c20..dae4f334841 100644 --- a/Sources/_NIOFileSystem/Internal/System Calls/Syscalls.swift +++ b/Sources/_NIOFileSystem/Internal/System Calls/Syscalls.swift @@ -149,6 +149,20 @@ internal func system_symlink( return symlink(destination, source) } +/// symlinkat(2): Make symbolic link to a file relative to directory file descriptor +internal func system_symlinkat( + _ destination: UnsafePointer, + _ dirfd: FileDescriptor.RawValue, + _ source: UnsafePointer +) -> CInt { + #if ENABLE_MOCKING + if mockingEnabled { + return mock(destination, dirfd, source) + } + #endif + return symlinkat(destination, dirfd, source) +} + /// readlink(2): Read value of a symolic link internal func system_readlink( _ path: UnsafePointer, @@ -271,6 +285,21 @@ internal func system_renamex_np( #endif return renamex_np(old, new, flags) } + +internal func system_renameatx_np( + _ oldFD: FileDescriptor.RawValue, + _ old: UnsafePointer, + _ newFD: FileDescriptor.RawValue, + _ new: UnsafePointer, + _ flags: CUnsignedInt +) -> CInt { + #if ENABLE_MOCKING + if mockingEnabled { + return mock(oldFD, old, newFD, new, flags) + } + #endif + return renameatx_np(oldFD, old, newFD, new, flags) +} #endif #if canImport(Glibc) || canImport(Musl) || canImport(Android) @@ -333,6 +362,20 @@ internal func system_unlink( return unlink(path) } +/// unlinkat(2): Remove a directory entry relative to a directory file descriptor. +internal func system_unlinkat( + _ fd: FileDescriptor.RawValue, + _ path: UnsafePointer, + _ flags: CInt +) -> CInt { + #if ENABLE_MOCKING + if mockingEnabled { + return mock(fd, path, flags) + } + #endif + return unlinkat(fd, path, flags) +} + #if canImport(Glibc) || canImport(Musl) || canImport(Android) /// sendfile(2): Transfer data between descriptors internal func system_sendfile( diff --git a/Tests/NIOFSIntegrationTests/FileSystemTests.swift b/Tests/NIOFSIntegrationTests/FileSystemTests.swift index b709a57666f..f909bbb74c4 100644 --- a/Tests/NIOFSIntegrationTests/FileSystemTests.swift +++ b/Tests/NIOFSIntegrationTests/FileSystemTests.swift @@ -1082,6 +1082,456 @@ final class FileSystemTests: XCTestCase { } } + func testCopyFileOverwritingExistingFile() async throws { + let sourceFileContent: [UInt8] = [1, 2, 3] + let existingFileContent: [UInt8] = [4, 5, 6] + + let source = try await self.fs.temporaryFilePath() + let destination = try await self.fs.temporaryFilePath() + + _ = try await self.fs.withFileHandle( + forWritingAt: source, + options: .newFile(replaceExisting: false) + ) { handle in + try await handle.write( + contentsOf: sourceFileContent, + toAbsoluteOffset: 0 + ) + } + + _ = try await self.fs.withFileHandle( + forWritingAt: destination, + options: .newFile(replaceExisting: false) + ) { handle in + try await handle.write( + contentsOf: existingFileContent, + toAbsoluteOffset: 0 + ) + } + + try await self.fs.copyItem( + at: source, + to: destination, + strategy: .platformDefault, + overwriting: true, + shouldProceedAfterError: { _, error in + throw error + }, + shouldCopyItem: { _, _ in + true + } + ) + + // Verify destination now has source content + try await self.fs.withFileHandle(forReadingAt: destination) { handle in + let contents = try await handle.readToEnd(maximumSizeAllowed: .bytes(1024)) + XCTAssertEqual(Array(buffer: contents), sourceFileContent) + } + + // Verify source still exists with original content + try await self.fs.withFileHandle(forReadingAt: source) { handle in + let contents = try await handle.readToEnd(maximumSizeAllowed: .bytes(1024)) + XCTAssertEqual(Array(buffer: contents), sourceFileContent) + } + } + + func testCopyFileOverwritingNonExistingFile() async throws { + let sourceContent: [UInt8] = [7, 8, 9] + + let source = try await self.fs.temporaryFilePath() + let destination = try await self.fs.temporaryFilePath() + + _ = try await self.fs.withFileHandle( + forWritingAt: source, + options: .newFile(replaceExisting: false) + ) { handle in + try await handle.write( + contentsOf: sourceContent, + toAbsoluteOffset: 0 + ) + } + + try await self.fs.copyItem( + at: source, + to: destination, + strategy: .platformDefault, + overwriting: true, + shouldProceedAfterError: { _, error in + throw error + }, + shouldCopyItem: { _, _ in + true + } + ) + + // Verify destination now exists with expected content + try await self.fs.withFileHandle(forReadingAt: destination) { handle in + let contents = try await handle.readToEnd(maximumSizeAllowed: .bytes(1024)) + XCTAssertEqual(Array(buffer: contents), sourceContent) + } + } + + func testCopyFileOverwritingCleansUpTempFile() async throws { + #if canImport(Glibc) || canImport(Musl) || canImport(Bionic) + let sourceContent: [UInt8] = [1, 2, 3] + let destinationContent: [UInt8] = [4, 5, 6] + + let source = try await self.fs.temporaryFilePath() + let destination = try await self.fs.temporaryFilePath() + + _ = try await self.fs.withFileHandle( + forWritingAt: source, + options: .newFile(replaceExisting: false) + ) { handle in + try await handle.write( + contentsOf: sourceContent, + toAbsoluteOffset: 0 + ) + } + + _ = try await self.fs.withFileHandle( + forWritingAt: destination, + options: .newFile(replaceExisting: false) + ) { handle in + try await handle.write( + contentsOf: destinationContent, + toAbsoluteOffset: 0 + ) + } + + try await self.fs.copyItem( + at: source, + to: destination, + strategy: .platformDefault, + overwriting: true, + shouldProceedAfterError: { _, error in throw error }, + shouldCopyItem: { _, _ in true } + ) + + let destinationDirectory = NIOFilePath(destination.underlying.removingLastComponent()) + + // Verify no .tmp- files are left after copying the file + let temporaryFiles = try await self.fs.withDirectoryHandle( + atPath: destinationDirectory + ) { dir in + var temporaryFiles: [String] = [] + for try await batch in dir.listContents().batched() { + for entry in batch where entry.name.hasPrefix(".tmp-") { + temporaryFiles.append(entry.name) + } + } + return temporaryFiles + } + + XCTAssertTrue(temporaryFiles.isEmpty, "Found temp files: \(temporaryFiles)") + + // Verify destination has expected content + try await self.fs.withFileHandle(forReadingAt: destination) { handle in + let contents = try await handle.readToEnd(maximumSizeAllowed: .bytes(1024)) + XCTAssertEqual(Array(buffer: contents), sourceContent) + } + #else + throw XCTSkip("Darwin uses copyfile which doesn't create temp files during overwriting") + #endif + } + + func testCopySymlinkOverwritingExistingSymlink() async throws { + // we use empty files to create symlinks to point to + let sourceTarget = try await self.fs.temporaryFilePath() + let destinationTarget = try await self.fs.temporaryFilePath() + + let sourceSymlink = try await self.fs.temporaryFilePath() + let destinationSymlink = try await self.fs.temporaryFilePath() + + try await self.fs.withFileHandle( + forWritingAt: sourceTarget, + options: .newFile(replaceExisting: false) + ) { _ in } + try await self.fs.withFileHandle( + forWritingAt: destinationTarget, + options: .newFile(replaceExisting: false) + ) { _ in } + + try await self.fs.createSymbolicLink( + at: sourceSymlink, + withDestination: sourceTarget + ) + try await self.fs.createSymbolicLink( + at: destinationSymlink, + withDestination: destinationTarget + ) + + try await self.fs.copyItem( + at: sourceSymlink, + to: destinationSymlink, + strategy: .platformDefault, + overwriting: true, + shouldProceedAfterError: { _, error in + throw error + }, + shouldCopyItem: { _, _ in + true + } + ) + + // Verify destination symlink now points to sourceTarget + let destinationTargetAfterCopy = try await self.fs.destinationOfSymbolicLink(at: destinationSymlink) + XCTAssertEqual(destinationTargetAfterCopy, sourceTarget) + + // Verify source symlink still exists and points to sourceTarget + let sourceTargetAfterCopy = try await self.fs.destinationOfSymbolicLink(at: sourceSymlink) + XCTAssertEqual(sourceTargetAfterCopy, sourceTarget) + } + + func testCopySymlinkOverwritingNonExistingSymlink() async throws { + let sourceTarget = try await self.fs.temporaryFilePath() + try await self.fs.withFileHandle( + forWritingAt: sourceTarget, + options: .newFile(replaceExisting: false) + ) { _ in } + + let sourceSymlink = try await self.fs.temporaryFilePath() + try await self.fs.createSymbolicLink( + at: sourceSymlink, + withDestination: sourceTarget + ) + let destinationSymlink = try await self.fs.temporaryFilePath() + + try await self.fs.copyItem( + at: sourceSymlink, + to: destinationSymlink, + strategy: .platformDefault, + overwriting: true, + shouldProceedAfterError: { _, error in + throw error + }, + shouldCopyItem: { _, _ in + true + } + ) + + // Verify destination symlink now exists and points to sourceTarget + let destinationTargetAfterCopy = try await self.fs.destinationOfSymbolicLink(at: destinationSymlink) + XCTAssertEqual(destinationTargetAfterCopy, sourceTarget) + } + + func testCopyFileOverwritingExistingSymlink() async throws { + let sourceFileContent: [UInt8] = [1, 2, 3] + let sourceFile = try await self.fs.temporaryFilePath() + + _ = try await self.fs.withFileHandle( + forWritingAt: sourceFile, + options: .newFile(replaceExisting: false) + ) { handle in + try await handle.write( + contentsOf: sourceFileContent, + toAbsoluteOffset: 0 + ) + } + + let symlinkTarget = try await self.fs.temporaryFilePath() + try await self.fs.withFileHandle( + forWritingAt: symlinkTarget, + options: .newFile(replaceExisting: false) + ) { _ in } + + let destinationSymlink = try await self.fs.temporaryFilePath() + try await self.fs.createSymbolicLink( + at: destinationSymlink, + withDestination: symlinkTarget + ) + + try await self.fs.copyItem( + at: sourceFile, + to: destinationSymlink, + strategy: .platformDefault, + overwriting: true, + shouldProceedAfterError: { _, error in + throw error + }, + shouldCopyItem: { _, _ in + true + } + ) + + // Verify destination is now a regular file + let destinationInfoAfterCopy = try await self.fs.info( + forFileAt: destinationSymlink, + infoAboutSymbolicLink: true + ) + XCTAssertEqual(destinationInfoAfterCopy?.type, .regular) + + // Verify destination has the source file's content + try await self.fs.withFileHandle(forReadingAt: destinationSymlink) { handle in + let contents = try await handle.readToEnd(maximumSizeAllowed: .bytes(1024)) + XCTAssertEqual(Array(buffer: contents), sourceFileContent) + } + + // Verify source file still exists + let sourceInfoAfterCopy = try await self.fs.info(forFileAt: sourceFile) + XCTAssertEqual(sourceInfoAfterCopy?.type, .regular) + } + + func testCopySymlinkOverwritingExistingFile() async throws { + let sourceSymlinkTarget = try await self.fs.temporaryFilePath() + try await self.fs.withFileHandle( + forWritingAt: sourceSymlinkTarget, + options: .newFile(replaceExisting: false) + ) { _ in } + + let destinationFileContent: [UInt8] = [4, 5, 6] + let destinationFile = try await self.fs.temporaryFilePath() + _ = try await self.fs.withFileHandle( + forWritingAt: destinationFile, + options: .newFile(replaceExisting: false) + ) { handle in + try await handle.write( + contentsOf: destinationFileContent, + toAbsoluteOffset: 0 + ) + } + + let sourceSymlink = try await self.fs.temporaryFilePath() + try await self.fs.createSymbolicLink( + at: sourceSymlink, + withDestination: sourceSymlinkTarget + ) + + try await self.fs.copyItem( + at: sourceSymlink, + to: destinationFile, + strategy: .platformDefault, + overwriting: true, + shouldProceedAfterError: { _, error in + throw error + }, + shouldCopyItem: { _, _ in + true + } + ) + + // Verify destination is now a symlink + let destinationInfoAfterCopy = try await self.fs.info( + forFileAt: destinationFile, + infoAboutSymbolicLink: true + ) + XCTAssertEqual(destinationInfoAfterCopy?.type, .symlink) + + // Verify destination symlink points to the expected target + let destinationTargetAfterCopy = try await self.fs.destinationOfSymbolicLink(at: destinationFile) + XCTAssertEqual(destinationTargetAfterCopy, sourceSymlinkTarget) + + // Verify source symlink still exists + let sourceInfoAfterCopy = try await self.fs.info(forFileAt: sourceSymlink, infoAboutSymbolicLink: true) + XCTAssertEqual(sourceInfoAfterCopy?.type, .symlink) + } + + func testCopySymlinkOverwritingCleansUpTempLink() async throws { + let testDirectory = try await self.fs.temporaryFilePath() + try await self.fs.createDirectory( + at: testDirectory, + withIntermediateDirectories: false, + permissions: nil + ) + + let sourceTarget = NIOFilePath(testDirectory.underlying.appending("source-target")) + try await self.fs.withFileHandle( + forWritingAt: sourceTarget, + options: .newFile(replaceExisting: false) + ) { _ in } + + let destinationTarget = NIOFilePath(testDirectory.underlying.appending("destination-target")) + try await self.fs.withFileHandle( + forWritingAt: destinationTarget, + options: .newFile(replaceExisting: false) + ) { _ in } + + let sourceSymlink = NIOFilePath(testDirectory.underlying.appending("source-symlink")) + try await self.fs.createSymbolicLink( + at: sourceSymlink, + withDestination: sourceTarget + ) + + let destinationSymlink = NIOFilePath(testDirectory.underlying.appending("destination-symlink")) + try await self.fs.createSymbolicLink( + at: destinationSymlink, + withDestination: destinationTarget + ) + + try await self.fs.copyItem( + at: sourceSymlink, + to: destinationSymlink, + strategy: .platformDefault, + overwriting: true, + shouldProceedAfterError: { _, error in throw error }, + shouldCopyItem: { _, _ in true } + ) + + // Verify no .tmp-link- files are left after copying the symlink + let temporaryFiles = try await self.fs.withDirectoryHandle( + atPath: testDirectory + ) { dir in + var temporaryFiles: [String] = [] + for try await batch in dir.listContents().batched() { + for entry in batch where entry.name.hasPrefix(".tmp-link-") { + temporaryFiles.append(entry.name) + } + } + return temporaryFiles + } + + XCTAssertTrue(temporaryFiles.isEmpty, "Found temp symlink files: \(temporaryFiles)") + + // Verify destination symlink points to expected target + let destinationTargetAfterCopy = try await self.fs.destinationOfSymbolicLink(at: destinationSymlink) + XCTAssertEqual(destinationTargetAfterCopy, sourceTarget) + } + + func testCopyFileOverwritingPreservesPermissions() async throws { + let sourceContent: [UInt8] = [1, 2, 3] + let destinationContent: [UInt8] = [4, 5, 6] + + let source = try await self.fs.temporaryFilePath() + _ = try await self.fs.withFileHandle( + forWritingAt: source, + options: .newFile(replaceExisting: false, permissions: .ownerReadWrite) + ) { handle in + try await handle.write(contentsOf: sourceContent, toAbsoluteOffset: 0) + } + + let destination = try await self.fs.temporaryFilePath() + _ = try await self.fs.withFileHandle( + forWritingAt: destination, + options: .newFile( + replaceExisting: false, + permissions: .ownerReadWriteExecute // different permissions + ) + ) { handle in + try await handle.write(contentsOf: destinationContent, toAbsoluteOffset: 0) + } + + try await self.fs.copyItem( + at: source, + to: destination, + strategy: .platformDefault, + overwriting: true, + shouldProceedAfterError: { _, error in throw error }, + shouldCopyItem: { _, _ in true } + ) + + // verify destination has source's permissions after copying + try await self.fs.withFileHandle(forReadingAt: destination) { handle in + let info = try await handle.info() + XCTAssertEqual(info.permissions, .ownerReadWrite) + } + + // verify destination has source content + try await self.fs.withFileHandle(forReadingAt: destination) { handle in + let contents = try await handle.readToEnd(maximumSizeAllowed: .bytes(1024)) + XCTAssertEqual(Array(buffer: contents), sourceContent) + } + } + func testRemoveSingleFile() async throws { let path = try await self.fs.temporaryFilePath() try await self.fs.withFileHandle( diff --git a/Tests/NIOFSTests/Internal/SyscallTests.swift b/Tests/NIOFSTests/Internal/SyscallTests.swift index 451575f7b70..8d17a415df2 100644 --- a/Tests/NIOFSTests/Internal/SyscallTests.swift +++ b/Tests/NIOFSTests/Internal/SyscallTests.swift @@ -180,6 +180,18 @@ final class SyscallTests: XCTestCase { testCases.run() } + func test_unlinkat() throws { + let dirfd = FileDescriptor(rawValue: 42) + + let testCases = [ + MockTestCase(name: "unlinkat", .noInterrupt, 42, "path", 0) { _ in + try Syscall.unlinkat(path: "path", relativeTo: dirfd).get() + } + ] + + testCases.run() + } + func test_symlink() throws { let testCases = [ MockTestCase(name: "symlink", .noInterrupt, "one", "two") { _ in @@ -190,6 +202,18 @@ final class SyscallTests: XCTestCase { testCases.run() } + func test_symlinkat() throws { + let dirfd = FileDescriptor(rawValue: 42) + + let testCases = [ + MockTestCase(name: "symlinkat", .noInterrupt, "one", 42, "two") { _ in + try Syscall.symlinkat(to: "one", in: dirfd, from: "two").get() + } + ] + + testCases.run() + } + func test_readlink() throws { let testCases = [ MockTestCase( @@ -305,6 +329,46 @@ final class SyscallTests: XCTestCase { #endif } + func test_renameatx_np() throws { + #if canImport(Darwin) + let oldFD = FileDescriptor(rawValue: 13) + let newFD = FileDescriptor(rawValue: 42) + + let testCases: [MockTestCase] = [ + MockTestCase(name: "renameatx_np", .noInterrupt, 13, "old", 42, "new", 0) { _ in + _ = try Syscall.rename( + from: "old", + relativeTo: oldFD, + to: "new", + relativeTo: newFD, + options: [] + ).get() + }, + MockTestCase(name: "renameatx_np", .noInterrupt, 13, "old", 42, "new", 4) { _ in + _ = try Syscall.rename( + from: "old", + relativeTo: oldFD, + to: "new", + relativeTo: newFD, + options: [.exclusive] + ).get() + }, + MockTestCase(name: "renameatx_np", .noInterrupt, 13, "old", 42, "new", 2) { _ in + _ = try Syscall.rename( + from: "old", + relativeTo: oldFD, + to: "new", + relativeTo: newFD, + options: [.swap] + ).get() + }, + ] + testCases.run() + #else + throw XCTSkip("'renameatx_np' is only supported on Darwin") + #endif + } + func test_renameat2() throws { #if canImport(Glibc) || canImport(Bionic) let fd1 = FileDescriptor(rawValue: 13)