Skip to content

Commit f1656e9

Browse files
committed
Fix FileSystem read/writeFileContents to use Data API
- using the Data API fixes issues with long filenames on Windows - fix error handling to map NSErrors to FileSystemError
1 parent c4309ae commit f1656e9

File tree

3 files changed

+223
-107
lines changed

3 files changed

+223
-107
lines changed

Sources/TSCBasic/FileSystem.swift

Lines changed: 195 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -80,33 +80,107 @@ public struct FileSystemError: Error, Equatable, Sendable {
8080
/// The absolute path to the file associated with the error, if available.
8181
public let path: AbsolutePath?
8282

83-
public init(_ kind: Kind, _ path: AbsolutePath? = nil) {
83+
/// A localized message describing the error, if available.
84+
public let localizedMessage: String?
85+
86+
public init(_ kind: Kind, _ path: AbsolutePath? = nil, localizedMessage: String? = nil) {
8487
self.kind = kind
8588
self.path = path
86-
}
87-
}
88-
89-
extension FileSystemError: CustomNSError {
90-
public var errorUserInfo: [String : Any] {
91-
return [NSLocalizedDescriptionKey: "\(self)"]
89+
self.localizedMessage = localizedMessage
9290
}
9391
}
9492

9593
public extension FileSystemError {
96-
init(errno: Int32, _ path: AbsolutePath) {
94+
init(errno: Int32, _ path: AbsolutePath, localizedMessage: String? = nil) {
9795
switch errno {
9896
case TSCLibc.EACCES:
99-
self.init(.invalidAccess, path)
97+
self.init(.invalidAccess, path, localizedMessage: localizedMessage)
10098
case TSCLibc.EISDIR:
101-
self.init(.isDirectory, path)
99+
self.init(.isDirectory, path, localizedMessage: localizedMessage)
102100
case TSCLibc.ENOENT:
103-
self.init(.noEntry, path)
101+
self.init(.noEntry, path, localizedMessage: localizedMessage)
104102
case TSCLibc.ENOTDIR:
105-
self.init(.notDirectory, path)
103+
self.init(.notDirectory, path, localizedMessage: localizedMessage)
106104
case TSCLibc.EEXIST:
107-
self.init(.alreadyExistsAtDestination, path)
105+
self.init(.alreadyExistsAtDestination, path, localizedMessage: localizedMessage)
106+
default:
107+
self.init(.ioError(code: errno), path, localizedMessage: localizedMessage)
108+
}
109+
}
110+
111+
init(error: POSIXError, _ path: AbsolutePath, localizedMessage: String? = nil) {
112+
switch error.code {
113+
case .ENOENT:
114+
self.init(.noEntry, path, localizedMessage: localizedMessage)
115+
case .EACCES:
116+
self.init(.invalidAccess, path, localizedMessage: localizedMessage)
117+
case .EISDIR:
118+
self.init(.isDirectory, path, localizedMessage: localizedMessage)
119+
case .ENOTDIR:
120+
self.init(.notDirectory, path, localizedMessage: localizedMessage)
121+
case .EEXIST:
122+
self.init(.alreadyExistsAtDestination, path, localizedMessage: localizedMessage)
123+
default:
124+
self.init(.ioError(code: error.code.rawValue), path, localizedMessage: localizedMessage)
125+
}
126+
}
127+
}
128+
129+
// MARK: - NSError to FileSystemError Mapping
130+
extension FileSystemError {
131+
/// Maps NSError codes to appropriate FileSystemError kinds
132+
/// This centralizes error mapping logic and ensures consistency across file operations
133+
///
134+
/// - Parameters:
135+
/// - error: The NSError to map
136+
/// - path: The file path associated with the error
137+
/// - Returns: A FileSystemError with appropriate semantic mapping
138+
static func from(nsError error: NSError, path: AbsolutePath) -> FileSystemError {
139+
// Extract localized description from NSError
140+
let localizedMessage = error.localizedDescription.isEmpty ? nil : error.localizedDescription
141+
142+
// First, check for POSIX errors in the underlying error chain
143+
// POSIX errors provide more precise semantic information
144+
if let posixError = error.userInfo[NSUnderlyingErrorKey] as? POSIXError {
145+
return FileSystemError(error: posixError, path, localizedMessage: localizedMessage)
146+
}
147+
148+
// Handle Cocoa domain errors with proper semantic mapping
149+
guard error.domain == NSCocoaErrorDomain else {
150+
// For non-Cocoa errors, preserve the original error information
151+
return FileSystemError(.ioError(code: Int32(error.code)), path, localizedMessage: localizedMessage)
152+
}
153+
154+
// Map common Cocoa error codes to semantic FileSystemError kinds
155+
switch error.code {
156+
// File not found errors
157+
case NSFileReadNoSuchFileError, NSFileNoSuchFileError:
158+
return FileSystemError(.noEntry, path, localizedMessage: localizedMessage)
159+
160+
// Permission denied errors
161+
case NSFileReadNoPermissionError, NSFileWriteNoPermissionError:
162+
return FileSystemError(.invalidAccess, path, localizedMessage: localizedMessage)
163+
164+
// File already exists errors
165+
case NSFileWriteFileExistsError:
166+
return FileSystemError(.alreadyExistsAtDestination, path, localizedMessage: localizedMessage)
167+
168+
// Read-only volume errors
169+
case NSFileWriteVolumeReadOnlyError:
170+
return FileSystemError(.invalidAccess, path, localizedMessage: localizedMessage)
171+
172+
// File corruption or invalid format errors
173+
case NSFileReadCorruptFileError:
174+
return FileSystemError(.ioError(code: Int32(error.code)), path, localizedMessage: localizedMessage)
175+
176+
// Directory-related errors
177+
case NSFileReadInvalidFileNameError:
178+
return FileSystemError(.notDirectory, path, localizedMessage: localizedMessage)
179+
108180
default:
109-
self.init(.ioError(code: errno), path)
181+
// For any other Cocoa error, wrap it as an IO error preserving the original code
182+
// This ensures we don't lose diagnostic information
183+
return FileSystemError(.ioError(code: Int32(error.code)), path, localizedMessage: localizedMessage)
110184
}
111185
}
112186
}
@@ -411,8 +485,15 @@ private struct LocalFileSystem: FileSystem {
411485
}
412486

413487
func getFileInfo(_ path: AbsolutePath) throws -> FileInfo {
414-
let attrs = try FileManager.default.attributesOfItem(atPath: path.pathString)
415-
return FileInfo(attrs)
488+
do {
489+
let attrs = try FileManager.default.attributesOfItem(atPath: path.pathString)
490+
return FileInfo(attrs)
491+
} catch let error as NSError {
492+
throw FileSystemError.from(nsError: error, path: path)
493+
} catch {
494+
// Handle any other error types (e.g., Swift errors)
495+
throw FileSystemError(.unknownOSError, path)
496+
}
416497
}
417498

418499
func hasAttribute(_ name: FileSystemAttribute, _ path: AbsolutePath) -> Bool {
@@ -473,21 +554,20 @@ private struct LocalFileSystem: FileSystem {
473554
}
474555

475556
func getDirectoryContents(_ path: AbsolutePath) throws -> [String] {
476-
#if canImport(Darwin)
477-
return try FileManager.default.contentsOfDirectory(atPath: path.pathString)
478-
#else
479557
do {
480558
return try FileManager.default.contentsOfDirectory(atPath: path.pathString)
481559
} catch let error as NSError {
482-
// Fixup error from corelibs-foundation.
483-
if error.code == CocoaError.fileReadNoSuchFile.rawValue, !error.userInfo.keys.contains(NSLocalizedDescriptionKey) {
560+
if error.code == CocoaError.fileReadNoSuchFile.rawValue {
484561
var userInfo = error.userInfo
485562
userInfo[NSLocalizedDescriptionKey] = "The folder “\(path.basename)” doesn’t exist."
486-
throw NSError(domain: error.domain, code: error.code, userInfo: userInfo)
563+
throw FileSystemError.from(nsError: NSError(domain: error.domain, code: error.code, userInfo: userInfo), path: path)
487564
}
488-
throw error
565+
// Convert NSError to FileSystemError with proper semantic mapping
566+
throw FileSystemError.from(nsError: error, path: path)
567+
} catch {
568+
// Handle any other error types (e.g., Swift errors)
569+
throw FileSystemError(.unknownOSError, path)
489570
}
490-
#endif
491571
}
492572

493573
func createDirectory(_ path: AbsolutePath, recursive: Bool) throws {
@@ -496,81 +576,78 @@ private struct LocalFileSystem: FileSystem {
496576

497577
do {
498578
try FileManager.default.createDirectory(atPath: path.pathString, withIntermediateDirectories: recursive, attributes: [:])
579+
} catch let error as NSError {
580+
if isDirectory(path) {
581+
// `createDirectory` failed but we have a directory now. This might happen if the directory is created
582+
// by another process between the check above and the call to `createDirectory`.
583+
// Since we have the expected end result, this is fine.
584+
return
585+
}
586+
throw FileSystemError.from(nsError: error, path: path)
499587
} catch {
500588
if isDirectory(path) {
501589
// `createDirectory` failed but we have a directory now. This might happen if the directory is created
502-
// by another process between the check above and the call to `createDirectory`.
590+
// by another process between the check above and the call to `createDirectory`.
503591
// Since we have the expected end result, this is fine.
504592
return
505593
}
506-
throw error
594+
// Handle any other error types (e.g., Swift errors)
595+
throw FileSystemError(.unknownOSError, path)
507596
}
508597
}
509598

510599
func createSymbolicLink(_ path: AbsolutePath, pointingAt destination: AbsolutePath, relative: Bool) throws {
511600
let destString = relative ? destination.relative(to: path.parentDirectory).pathString : destination.pathString
512-
try FileManager.default.createSymbolicLink(atPath: path.pathString, withDestinationPath: destString)
601+
do {
602+
try FileManager.default.createSymbolicLink(atPath: path.pathString, withDestinationPath: destString)
603+
} catch let error as NSError {
604+
throw FileSystemError.from(nsError: error, path: path)
605+
} catch {
606+
// Handle any other error types (e.g., Swift errors)
607+
throw FileSystemError(.unknownOSError, path)
608+
}
513609
}
514610

515611
func readFileContents(_ path: AbsolutePath) throws -> ByteString {
516-
// Open the file.
517-
guard let fp = fopen(path.pathString, "rb") else {
518-
throw FileSystemError(errno: errno, path)
519-
}
520-
defer { fclose(fp) }
521-
522-
// Read the data one block at a time.
523-
let data = BufferedOutputByteStream()
524-
var tmpBuffer = [UInt8](repeating: 0, count: 1 << 12)
525-
while true {
526-
let n = fread(&tmpBuffer, 1, tmpBuffer.count, fp)
527-
if n < 0 {
528-
if errno == EINTR { continue }
529-
throw FileSystemError(.ioError(code: errno), path)
530-
}
531-
if n == 0 {
532-
let errno = ferror(fp)
533-
if errno != 0 {
534-
throw FileSystemError(.ioError(code: errno), path)
535-
}
536-
break
612+
do {
613+
let dataContent = try Data(contentsOf: URL(fileURLWithPath: path.pathString))
614+
return dataContent.withUnsafeBytes { bytes in
615+
ByteString(Array(bytes.bindMemory(to: UInt8.self)))
537616
}
538-
data.send(tmpBuffer[0..<n])
617+
} catch let error as NSError {
618+
throw FileSystemError.from(nsError: error, path: path)
619+
} catch {
620+
// Handle any other error types (e.g., Swift errors)
621+
throw FileSystemError(.unknownOSError, path)
539622
}
540-
541-
return data.bytes
542623
}
543624

544625
func writeFileContents(_ path: AbsolutePath, bytes: ByteString) throws {
545-
// Open the file.
546-
guard let fp = fopen(path.pathString, "wb") else {
547-
throw FileSystemError(errno: errno, path)
548-
}
549-
defer { fclose(fp) }
550-
551-
// Write the data in one chunk.
552-
var contents = bytes.contents
553-
while true {
554-
let n = fwrite(&contents, 1, contents.count, fp)
555-
if n < 0 {
556-
if errno == EINTR { continue }
557-
throw FileSystemError(.ioError(code: errno), path)
558-
}
559-
if n != contents.count {
560-
throw FileSystemError(.mismatchedByteCount(expected: contents.count, actual: n), path)
626+
do {
627+
try bytes.withData {
628+
try $0.write(to: URL(fileURLWithPath: path.pathString))
561629
}
562-
break
630+
} catch let error as NSError {
631+
throw FileSystemError.from(nsError: error, path: path)
632+
} catch {
633+
// Handle any other error types (e.g., Swift errors)
634+
throw FileSystemError(.unknownOSError, path)
563635
}
564636
}
565637

566638
func writeFileContents(_ path: AbsolutePath, bytes: ByteString, atomically: Bool) throws {
567-
// Perform non-atomic writes using the fast path.
568639
if !atomically {
569640
return try writeFileContents(path, bytes: bytes)
570641
}
571-
572-
try bytes.withData {
573-
try $0.write(to: URL(fileURLWithPath: path.pathString), options: .atomic)
642+
do {
643+
try bytes.withData {
644+
try $0.write(to: URL(fileURLWithPath: path.pathString), options: .atomic)
645+
}
646+
} catch let error as NSError {
647+
throw FileSystemError.from(nsError: error, path: path)
648+
} catch {
649+
// Handle any other error types (e.g., Swift errors)
650+
throw FileSystemError(.unknownOSError, path)
574651
}
575652
}
576653

@@ -588,18 +665,26 @@ private struct LocalFileSystem: FileSystem {
588665
func chmod(_ mode: FileMode, path: AbsolutePath, options: Set<FileMode.Option>) throws {
589666
guard exists(path) else { return }
590667
func setMode(path: String) throws {
591-
let attrs = try FileManager.default.attributesOfItem(atPath: path)
592-
// Skip if only files should be changed.
593-
if options.contains(.onlyFiles) && attrs[.type] as? FileAttributeType != .typeRegular {
594-
return
595-
}
668+
do {
669+
let attrs = try FileManager.default.attributesOfItem(atPath: path)
670+
// Skip if only files should be changed.
671+
if options.contains(.onlyFiles) && attrs[.type] as? FileAttributeType != .typeRegular {
672+
return
673+
}
596674

597-
// Compute the new mode for this file.
598-
let currentMode = attrs[.posixPermissions] as! Int16
599-
let newMode = mode.setMode(currentMode)
600-
guard newMode != currentMode else { return }
601-
try FileManager.default.setAttributes([.posixPermissions : newMode],
602-
ofItemAtPath: path)
675+
// Compute the new mode for this file.
676+
let currentMode = attrs[.posixPermissions] as! Int16
677+
let newMode = mode.setMode(currentMode)
678+
guard newMode != currentMode else { return }
679+
try FileManager.default.setAttributes([.posixPermissions : newMode],
680+
ofItemAtPath: path)
681+
} catch let error as NSError {
682+
let absolutePath = try AbsolutePath(validating: path)
683+
throw FileSystemError.from(nsError: error, path: absolutePath)
684+
} catch {
685+
let absolutePath = try AbsolutePath(validating: path)
686+
throw FileSystemError(.unknownOSError, absolutePath)
687+
}
603688
}
604689

605690
try setMode(path: path.pathString)
@@ -624,14 +709,28 @@ private struct LocalFileSystem: FileSystem {
624709
guard exists(sourcePath) else { throw FileSystemError(.noEntry, sourcePath) }
625710
guard !exists(destinationPath)
626711
else { throw FileSystemError(.alreadyExistsAtDestination, destinationPath) }
627-
try FileManager.default.copyItem(at: sourcePath.asURL, to: destinationPath.asURL)
712+
do {
713+
try FileManager.default.copyItem(at: sourcePath.asURL, to: destinationPath.asURL)
714+
} catch let error as NSError {
715+
throw FileSystemError.from(nsError: error, path: destinationPath)
716+
} catch {
717+
// Handle any other error types (e.g., Swift errors)
718+
throw FileSystemError(.unknownOSError, destinationPath)
719+
}
628720
}
629721

630722
func move(from sourcePath: AbsolutePath, to destinationPath: AbsolutePath) throws {
631723
guard exists(sourcePath) else { throw FileSystemError(.noEntry, sourcePath) }
632724
guard !exists(destinationPath)
633725
else { throw FileSystemError(.alreadyExistsAtDestination, destinationPath) }
634-
try FileManager.default.moveItem(at: sourcePath.asURL, to: destinationPath.asURL)
726+
do {
727+
try FileManager.default.moveItem(at: sourcePath.asURL, to: destinationPath.asURL)
728+
} catch let error as NSError {
729+
throw FileSystemError.from(nsError: error, path: destinationPath)
730+
} catch {
731+
// Handle any other error types (e.g., Swift errors)
732+
throw FileSystemError(.unknownOSError, destinationPath)
733+
}
635734
}
636735

637736
func withLock<T>(on path: AbsolutePath, type: FileLock.LockType, blocking: Bool, _ body: () throws -> T) throws -> T {
@@ -648,10 +747,17 @@ private struct LocalFileSystem: FileSystem {
648747
}
649748

650749
func itemReplacementDirectories(for path: AbsolutePath) throws -> [AbsolutePath] {
651-
let result = try FileManager.default.url(for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: path.asURL, create: false)
652-
let path = try AbsolutePath(validating: result.path)
653-
// Foundation returns a path that is unique every time, so we return both that path, as well as its parent.
654-
return [path, path.parentDirectory]
750+
do {
751+
let result = try FileManager.default.url(for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: path.asURL, create: false)
752+
let resultPath = try AbsolutePath(validating: result.path)
753+
// Foundation returns a path that is unique every time, so we return both that path, as well as its parent.
754+
return [resultPath, resultPath.parentDirectory]
755+
} catch let error as NSError {
756+
throw FileSystemError.from(nsError: error, path: path)
757+
} catch {
758+
// Handle any other error types (e.g., Swift errors)
759+
throw FileSystemError(.unknownOSError, path)
760+
}
655761
}
656762
}
657763

0 commit comments

Comments
 (0)