diff --git a/Sources/TSCBasic/FileSystem.swift b/Sources/TSCBasic/FileSystem.swift index 43988cb8..1ee457bc 100644 --- a/Sources/TSCBasic/FileSystem.swift +++ b/Sources/TSCBasic/FileSystem.swift @@ -12,7 +12,7 @@ import TSCLibc import Foundation import Dispatch -public struct FileSystemError: Error, Equatable, Sendable { +public struct FileSystemError: Error, Sendable { public enum Kind: Equatable, Sendable { /// Access to the path is denied. /// @@ -80,33 +80,121 @@ public struct FileSystemError: Error, Equatable, Sendable { /// The absolute path to the file associated with the error, if available. public let path: AbsolutePath? - public init(_ kind: Kind, _ path: AbsolutePath? = nil) { + /// A localized message describing the error, if available. + public let localizedMessage: String? + + public init(_ kind: Kind, _ path: AbsolutePath? = nil, localizedMessage: String? = nil) { self.kind = kind self.path = path + self.localizedMessage = localizedMessage } } extension FileSystemError: CustomNSError { public var errorUserInfo: [String : Any] { - return [NSLocalizedDescriptionKey: "\(self)"] + return [NSLocalizedDescriptionKey: self.localizedMessage] + } +} + +// MARK: - Equatable Implementation +extension FileSystemError: Equatable { + /// Custom equality implementation that ignores localizedMessage + public static func == (lhs: FileSystemError, rhs: FileSystemError) -> Bool { + return lhs.kind == rhs.kind && lhs.path == rhs.path } } public extension FileSystemError { - init(errno: Int32, _ path: AbsolutePath) { + init(errno: Int32, _ path: AbsolutePath, localizedMessage: String? = nil) { switch errno { case TSCLibc.EACCES: - self.init(.invalidAccess, path) + self.init(.invalidAccess, path, localizedMessage: localizedMessage) case TSCLibc.EISDIR: - self.init(.isDirectory, path) + self.init(.isDirectory, path, localizedMessage: localizedMessage) case TSCLibc.ENOENT: - self.init(.noEntry, path) + self.init(.noEntry, path, localizedMessage: localizedMessage) case TSCLibc.ENOTDIR: - self.init(.notDirectory, path) + self.init(.notDirectory, path, localizedMessage: localizedMessage) case TSCLibc.EEXIST: - self.init(.alreadyExistsAtDestination, path) + self.init(.alreadyExistsAtDestination, path, localizedMessage: localizedMessage) + default: + self.init(.ioError(code: errno), path, localizedMessage: localizedMessage) + } + } + + init(error: POSIXError, _ path: AbsolutePath, localizedMessage: String? = nil) { + switch error.code { + case .ENOENT: + self.init(.noEntry, path, localizedMessage: localizedMessage) + case .EACCES: + self.init(.invalidAccess, path, localizedMessage: localizedMessage) + case .EISDIR: + self.init(.isDirectory, path, localizedMessage: localizedMessage) + case .ENOTDIR: + self.init(.notDirectory, path, localizedMessage: localizedMessage) + case .EEXIST: + self.init(.alreadyExistsAtDestination, path, localizedMessage: localizedMessage) + default: + self.init(.ioError(code: error.code.rawValue), path, localizedMessage: localizedMessage) + } + } +} + +// MARK: - NSError to FileSystemError Mapping +extension FileSystemError { + /// Maps NSError codes to appropriate FileSystemError kinds + /// This centralizes error mapping logic and ensures consistency across file operations + /// + /// - Parameters: + /// - error: The NSError to map + /// - path: The file path associated with the error + /// - Returns: A FileSystemError with appropriate semantic mapping + static func from(nsError error: NSError, path: AbsolutePath) -> FileSystemError { + // Extract localized description from NSError + let localizedMessage = error.localizedDescription.isEmpty ? nil : error.localizedDescription + + // First, check for POSIX errors in the underlying error chain + // POSIX errors provide more precise semantic information + if let posixError = error.userInfo[NSUnderlyingErrorKey] as? POSIXError { + return FileSystemError(error: posixError, path, localizedMessage: localizedMessage) + } + + // Handle Cocoa domain errors with proper semantic mapping + guard error.domain == NSCocoaErrorDomain else { + // For non-Cocoa errors, preserve the original error information + return FileSystemError(.ioError(code: Int32(error.code)), path, localizedMessage: localizedMessage) + } + + // Map common Cocoa error codes to semantic FileSystemError kinds + switch error.code { + // File not found errors + case NSFileReadNoSuchFileError, NSFileNoSuchFileError: + return FileSystemError(.noEntry, path, localizedMessage: localizedMessage) + + // Permission denied errors + case NSFileReadNoPermissionError, NSFileWriteNoPermissionError: + return FileSystemError(.invalidAccess, path, localizedMessage: localizedMessage) + + // File already exists errors + case NSFileWriteFileExistsError: + return FileSystemError(.alreadyExistsAtDestination, path, localizedMessage: localizedMessage) + + // Read-only volume errors + case NSFileWriteVolumeReadOnlyError: + return FileSystemError(.invalidAccess, path, localizedMessage: localizedMessage) + + // File corruption or invalid format errors + case NSFileReadCorruptFileError: + return FileSystemError(.ioError(code: Int32(error.code)), path, localizedMessage: localizedMessage) + + // Directory-related errors + case NSFileReadInvalidFileNameError: + return FileSystemError(.notDirectory, path, localizedMessage: localizedMessage) + default: - self.init(.ioError(code: errno), path) + // For any other Cocoa error, wrap it as an IO error preserving the original code + // This ensures we don't lose diagnostic information + return FileSystemError(.ioError(code: Int32(error.code)), path, localizedMessage: localizedMessage) } } } @@ -411,8 +499,15 @@ private struct LocalFileSystem: FileSystem { } func getFileInfo(_ path: AbsolutePath) throws -> FileInfo { - let attrs = try FileManager.default.attributesOfItem(atPath: path.pathString) - return FileInfo(attrs) + do { + let attrs = try FileManager.default.attributesOfItem(atPath: path.pathString) + return FileInfo(attrs) + } catch let error as NSError { + throw FileSystemError.from(nsError: error, path: path) + } catch { + // Handle any other error types (e.g., Swift errors) + throw FileSystemError(.unknownOSError, path) + } } func hasAttribute(_ name: FileSystemAttribute, _ path: AbsolutePath) -> Bool { @@ -473,9 +568,6 @@ private struct LocalFileSystem: FileSystem { } func getDirectoryContents(_ path: AbsolutePath) throws -> [String] { -#if canImport(Darwin) - return try FileManager.default.contentsOfDirectory(atPath: path.pathString) -#else do { return try FileManager.default.contentsOfDirectory(atPath: path.pathString) } catch let error as NSError { @@ -483,11 +575,14 @@ private struct LocalFileSystem: FileSystem { if error.code == CocoaError.fileReadNoSuchFile.rawValue, !error.userInfo.keys.contains(NSLocalizedDescriptionKey) { var userInfo = error.userInfo userInfo[NSLocalizedDescriptionKey] = "The folder “\(path.basename)” doesn’t exist." - throw NSError(domain: error.domain, code: error.code, userInfo: userInfo) + throw FileSystemError.from(nsError: NSError(domain: error.domain, code: error.code, userInfo: userInfo), path: path) } - throw error + // Convert NSError to FileSystemError with proper semantic mapping + throw FileSystemError.from(nsError: error, path: path) + } catch { + // Handle any other error types (e.g., Swift errors) + throw FileSystemError(.unknownOSError, path) } -#endif } func createDirectory(_ path: AbsolutePath, recursive: Bool) throws { @@ -496,81 +591,78 @@ private struct LocalFileSystem: FileSystem { do { try FileManager.default.createDirectory(atPath: path.pathString, withIntermediateDirectories: recursive, attributes: [:]) + } catch let error as NSError { + if isDirectory(path) { + // `createDirectory` failed but we have a directory now. This might happen if the directory is created + // by another process between the check above and the call to `createDirectory`. + // Since we have the expected end result, this is fine. + return + } + throw FileSystemError.from(nsError: error, path: path) } catch { if isDirectory(path) { // `createDirectory` failed but we have a directory now. This might happen if the directory is created - // by another process between the check above and the call to `createDirectory`. + // by another process between the check above and the call to `createDirectory`. // Since we have the expected end result, this is fine. return } - throw error + // Handle any other error types (e.g., Swift errors) + throw FileSystemError(.unknownOSError, path) } } func createSymbolicLink(_ path: AbsolutePath, pointingAt destination: AbsolutePath, relative: Bool) throws { let destString = relative ? destination.relative(to: path.parentDirectory).pathString : destination.pathString - try FileManager.default.createSymbolicLink(atPath: path.pathString, withDestinationPath: destString) + do { + try FileManager.default.createSymbolicLink(atPath: path.pathString, withDestinationPath: destString) + } catch let error as NSError { + throw FileSystemError.from(nsError: error, path: path) + } catch { + // Handle any other error types (e.g., Swift errors) + throw FileSystemError(.unknownOSError, path) + } } func readFileContents(_ path: AbsolutePath) throws -> ByteString { - // Open the file. - guard let fp = fopen(path.pathString, "rb") else { - throw FileSystemError(errno: errno, path) - } - defer { fclose(fp) } - - // Read the data one block at a time. - let data = BufferedOutputByteStream() - var tmpBuffer = [UInt8](repeating: 0, count: 1 << 12) - while true { - let n = fread(&tmpBuffer, 1, tmpBuffer.count, fp) - if n < 0 { - if errno == EINTR { continue } - throw FileSystemError(.ioError(code: errno), path) - } - if n == 0 { - let errno = ferror(fp) - if errno != 0 { - throw FileSystemError(.ioError(code: errno), path) - } - break + do { + let dataContent = try Data(contentsOf: URL(fileURLWithPath: path.pathString)) + return dataContent.withUnsafeBytes { bytes in + ByteString(Array(bytes.bindMemory(to: UInt8.self))) } - data.send(tmpBuffer[0..) throws { guard exists(path) else { return } func setMode(path: String) throws { - let attrs = try FileManager.default.attributesOfItem(atPath: path) - // Skip if only files should be changed. - if options.contains(.onlyFiles) && attrs[.type] as? FileAttributeType != .typeRegular { - return - } + do { + let attrs = try FileManager.default.attributesOfItem(atPath: path) + // Skip if only files should be changed. + if options.contains(.onlyFiles) && attrs[.type] as? FileAttributeType != .typeRegular { + return + } - // Compute the new mode for this file. - let currentMode = attrs[.posixPermissions] as! Int16 - let newMode = mode.setMode(currentMode) - guard newMode != currentMode else { return } - try FileManager.default.setAttributes([.posixPermissions : newMode], - ofItemAtPath: path) + // Compute the new mode for this file. + let currentMode = attrs[.posixPermissions] as! Int16 + let newMode = mode.setMode(currentMode) + guard newMode != currentMode else { return } + try FileManager.default.setAttributes([.posixPermissions : newMode], + ofItemAtPath: path) + } catch let error as NSError { + let absolutePath = try AbsolutePath(validating: path) + throw FileSystemError.from(nsError: error, path: absolutePath) + } catch { + let absolutePath = try AbsolutePath(validating: path) + throw FileSystemError(.unknownOSError, absolutePath) + } } try setMode(path: path.pathString) @@ -624,14 +724,28 @@ private struct LocalFileSystem: FileSystem { guard exists(sourcePath) else { throw FileSystemError(.noEntry, sourcePath) } guard !exists(destinationPath) else { throw FileSystemError(.alreadyExistsAtDestination, destinationPath) } - try FileManager.default.copyItem(at: sourcePath.asURL, to: destinationPath.asURL) + do { + try FileManager.default.copyItem(at: sourcePath.asURL, to: destinationPath.asURL) + } catch let error as NSError { + throw FileSystemError.from(nsError: error, path: destinationPath) + } catch { + // Handle any other error types (e.g., Swift errors) + throw FileSystemError(.unknownOSError, destinationPath) + } } func move(from sourcePath: AbsolutePath, to destinationPath: AbsolutePath) throws { guard exists(sourcePath) else { throw FileSystemError(.noEntry, sourcePath) } guard !exists(destinationPath) else { throw FileSystemError(.alreadyExistsAtDestination, destinationPath) } - try FileManager.default.moveItem(at: sourcePath.asURL, to: destinationPath.asURL) + do { + try FileManager.default.moveItem(at: sourcePath.asURL, to: destinationPath.asURL) + } catch let error as NSError { + throw FileSystemError.from(nsError: error, path: destinationPath) + } catch { + // Handle any other error types (e.g., Swift errors) + throw FileSystemError(.unknownOSError, destinationPath) + } } func withLock(on path: AbsolutePath, type: FileLock.LockType, blocking: Bool, _ body: () throws -> T) throws -> T { @@ -648,10 +762,17 @@ private struct LocalFileSystem: FileSystem { } func itemReplacementDirectories(for path: AbsolutePath) throws -> [AbsolutePath] { - let result = try FileManager.default.url(for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: path.asURL, create: false) - let path = try AbsolutePath(validating: result.path) - // Foundation returns a path that is unique every time, so we return both that path, as well as its parent. - return [path, path.parentDirectory] + do { + let result = try FileManager.default.url(for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: path.asURL, create: false) + let resultPath = try AbsolutePath(validating: result.path) + // Foundation returns a path that is unique every time, so we return both that path, as well as its parent. + return [resultPath, resultPath.parentDirectory] + } catch let error as NSError { + throw FileSystemError.from(nsError: error, path: path) + } catch { + // Handle any other error types (e.g., Swift errors) + throw FileSystemError(.unknownOSError, path) + } } } diff --git a/Sources/TSCBasic/PathShims.swift b/Sources/TSCBasic/PathShims.swift index 59401a37..8851751a 100644 --- a/Sources/TSCBasic/PathShims.swift +++ b/Sources/TSCBasic/PathShims.swift @@ -71,7 +71,13 @@ public func resolveSymlinks(_ path: AbsolutePath) throws -> AbsolutePath { /// Creates a new, empty directory at `path`. If needed, any non-existent ancestor paths are also created. If there is /// already a directory at `path`, this function does nothing (in particular, this is not considered to be an error). public func makeDirectories(_ path: AbsolutePath) throws { - try FileManager.default.createDirectory(atPath: path.pathString, withIntermediateDirectories: true, attributes: [:]) + do { + try FileManager.default.createDirectory(atPath: path.pathString, withIntermediateDirectories: true, attributes: [:]) + } catch let error as NSError { + throw FileSystemError.from(nsError: error, path: path) + } catch { + throw FileSystemError(.unknownOSError, path) + } } /// Creates a symbolic link at `path` whose content points to `dest`. If `relative` is true, the symlink contents will @@ -79,7 +85,13 @@ public func makeDirectories(_ path: AbsolutePath) throws { @available(*, deprecated, renamed: "localFileSystem.createSymbolicLink") public func createSymlink(_ path: AbsolutePath, pointingAt dest: AbsolutePath, relative: Bool = true) throws { let destString = relative ? dest.relative(to: path.parentDirectory).pathString : dest.pathString - try FileManager.default.createSymbolicLink(atPath: path.pathString, withDestinationPath: destString) + do { + try FileManager.default.createSymbolicLink(atPath: path.pathString, withDestinationPath: destString) + } catch let error as NSError { + throw FileSystemError.from(nsError: error, path: path) + } catch { + throw FileSystemError(.unknownOSError, path) + } } /** diff --git a/Tests/TSCBasicTests/FileSystemTests.swift b/Tests/TSCBasicTests/FileSystemTests.swift index 07c55cac..e31b27cc 100644 --- a/Tests/TSCBasicTests/FileSystemTests.swift +++ b/Tests/TSCBasicTests/FileSystemTests.swift @@ -17,6 +17,38 @@ import TSCLibc class FileSystemTests: XCTestCase { // MARK: LocalFS Tests + + func testFileSystemErrorEquality() throws { + // Test that FileSystemError ignores localizedMessage when testing for equality + let path = try AbsolutePath(validating: "/test/path") + let error1 = FileSystemError(.noEntry, path, localizedMessage: "First message") + let error2 = FileSystemError(.noEntry, path, localizedMessage: "Different message") + let error3 = FileSystemError(.noEntry, path, localizedMessage: nil) + let error4 = FileSystemError(.invalidAccess, path, localizedMessage: "Some message") + + // Same kind and path, different messages should be equal + XCTAssertEqual(error1, error2) + XCTAssertEqual(error1, error3) + XCTAssertEqual(error2, error3) + + // Different kind, same path should not be equal + XCTAssertNotEqual(error1, error4) + + // Test with different paths + let differentPath = try AbsolutePath(validating: "/different/path") + let error5 = FileSystemError(.noEntry, differentPath, localizedMessage: "Message") + XCTAssertNotEqual(error1, error5) + + // Test with nil paths + let error6 = FileSystemError(.noEntry, nil, localizedMessage: "Message 1") + let error7 = FileSystemError(.noEntry, nil, localizedMessage: "Message 2") + XCTAssertEqual(error6, error7) + + // Test mixed nil and non-nil paths + let error8 = FileSystemError(.noEntry, path, localizedMessage: "Message") + let error9 = FileSystemError(.noEntry, nil, localizedMessage: "Message") + XCTAssertNotEqual(error8, error9) + } func testLocalBasics() throws { let fs = TSCBasic.localFileSystem @@ -44,6 +76,7 @@ class FileSystemTests: XCTestCase { XCTAssertFalse(fs.isDirectory(sym)) // isExecutableFile +#if !os(Windows) let executable = tempDirPath.appending(component: "exec-foo") let executableSym = tempDirPath.appending(component: "exec-sym") try! fs.createSymbolicLink(executableSym, pointingAt: executable, relative: false) @@ -63,7 +96,8 @@ class FileSystemTests: XCTestCase { XCTAssertFalse(fs.isExecutableFile(file.path)) XCTAssertFalse(fs.isExecutableFile("/does-not-exist")) XCTAssertFalse(fs.isExecutableFile("/")) - +#endif + // isDirectory() XCTAssert(fs.isDirectory("/")) XCTAssert(!fs.isDirectory("/does-not-exist")) @@ -183,6 +217,7 @@ class FileSystemTests: XCTestCase { } } +#if !os(Windows) func testLocalReadableWritable() throws { try testWithTemporaryDirectory { tmpdir in let fs = localFileSystem @@ -250,6 +285,7 @@ class FileSystemTests: XCTestCase { } } } +#endif func testLocalCreateDirectory() throws { let fs = TSCBasic.localFileSystem @@ -313,10 +349,21 @@ class FileSystemTests: XCTestCase { XCTAssertEqual(try! fs.readFileContents(filePath), "Hello, new world!") // Check read/write of a directory. - XCTAssertThrows(FileSystemError(.ioError(code: TSCLibc.EPERM), filePath.parentDirectory)) { + #if os(Windows) + var expectedError = FileSystemError(.invalidAccess, filePath.parentDirectory) + #else + var expectedError = FileSystemError(.isDirectory, filePath.parentDirectory) + #endif + XCTAssertThrows(expectedError) { _ = try fs.readFileContents(filePath.parentDirectory) } - XCTAssertThrows(FileSystemError(.isDirectory, filePath.parentDirectory)) { + #if os(Windows) + expectedError = FileSystemError(.invalidAccess, filePath.parentDirectory) + #else + expectedError = FileSystemError(.isDirectory, filePath.parentDirectory) + #endif + + XCTAssertThrows(expectedError) { try fs.writeFileContents(filePath.parentDirectory, bytes: []) } XCTAssertEqual(try! fs.readFileContents(filePath), "Hello, new world!") @@ -324,18 +371,26 @@ class FileSystemTests: XCTestCase { // Check read/write against root. #if os(Android) let root = AbsolutePath("/system/") + #elseif os(Windows) + let root = AbsolutePath("C:/Windows") #else let root = AbsolutePath("/") #endif - XCTAssertThrows(FileSystemError(.ioError(code: TSCLibc.EPERM), root)) { + #if os(Windows) + expectedError = FileSystemError(.invalidAccess, root) + #else + expectedError = FileSystemError(.isDirectory, root) + #endif + XCTAssertThrows(expectedError) { _ = try fs.readFileContents(root) - } #if os(macOS) // Newer versions of macOS end up with `EEXISTS` instead of `EISDIR` here. - let expectedError = FileSystemError(.alreadyExistsAtDestination, root) + expectedError = FileSystemError(.alreadyExistsAtDestination, root) + #elseif os(Windows) + expectedError = FileSystemError(.invalidAccess, root) #else - let expectedError = FileSystemError(.isDirectory, root) + expectedError = FileSystemError(.isDirectory, root) #endif XCTAssertThrows(expectedError) { try fs.writeFileContents(root, bytes: []) @@ -344,10 +399,20 @@ class FileSystemTests: XCTestCase { // Check read/write into a non-directory. let notDirectoryPath = filePath.appending(component: "not-possible") - XCTAssertThrows(FileSystemError(.notDirectory, notDirectoryPath)) { + #if os(Windows) + expectedError = FileSystemError(.noEntry, notDirectoryPath) + #else + expectedError = FileSystemError(.notDirectory, notDirectoryPath) + #endif + XCTAssertThrows(expectedError) { _ = try fs.readFileContents(notDirectoryPath) } - XCTAssertThrows(FileSystemError(.notDirectory, notDirectoryPath)) { + #if os(Windows) + expectedError = FileSystemError(.noEntry, notDirectoryPath) + #else + expectedError = FileSystemError(.notDirectory, notDirectoryPath) + #endif + XCTAssertThrows(expectedError) { try fs.writeFileContents(filePath.appending(component: "not-possible"), bytes: []) } XCTAssert(fs.exists(filePath))