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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -271,48 +271,36 @@ extension _FileManagerImpl {
SECURITY_ATTRIBUTES(nLength: DWORD(MemoryLayout<SECURITY_ATTRIBUTES>.size),
lpSecurityDescriptor: nil,
bInheritHandle: false)
// `CreateDirectoryW` does not create intermediate directories, so we need to handle that manually.
// Note: `SHCreateDirectoryExW` seems to have issues with long paths.
// `SHCreateDirectoryExW` creates intermediate directories while `CreateDirectoryW` does not.
if createIntermediates {
// Create intermediate directories recursively
func _createDirectoryRecursively(at directoryPath: String) throws {
try directoryPath.withNTPathRepresentation { pwszPath in
// Create this directory
guard CreateDirectoryW(pwszPath, &saAttributes) else {
let lastError = GetLastError()
if lastError == ERROR_ALREADY_EXISTS {
var isDir: Bool = false
if fileExists(atPath: directoryPath, isDirectory: &isDir), isDir {
return // Directory now exists, success
}
} else if lastError == ERROR_PATH_NOT_FOUND {
let parentPath = directoryPath.deletingLastPathComponent()
if !parentPath.isEmpty && parentPath != directoryPath {
// Recursively create parent directory
try _createDirectoryRecursively(at: parentPath)
// Now try creating this one again.
guard CreateDirectoryW(pwszPath, &saAttributes) else {
let lastError = GetLastError()
if lastError == ERROR_ALREADY_EXISTS {
var isDir: Bool = false
if fileExists(atPath: directoryPath, isDirectory: &isDir), isDir {
return // Directory now exists, success
}
}
throw CocoaError.errorWithFilePath(directoryPath, win32: lastError, reading: false)
}
return
}
}
throw CocoaError.errorWithFilePath(directoryPath, win32: lastError, reading: false)
// `SHCreateDirectoryExW` requires an absolute path while `CreateDirectoryW` works based on the current working
// directory.
try path.withNTPathRepresentation { pwszPath in
let errorCode = SHCreateDirectoryExW(nil, pwszPath, &saAttributes)
guard let errorCode = DWORD(exactly: errorCode) else {
// `SHCreateDirectoryExW` returns `Int` but all error codes are defined in terms of `DWORD`, aka
// `UInt`. We received an unknown error code.
throw CocoaError.errorWithFilePath(.fileWriteUnknown, path)
}
switch errorCode {
case ERROR_SUCCESS:
if let attributes {
try? fileManager.setAttributes(attributes, ofItemAtPath: path)
}
case ERROR_ALREADY_EXISTS:
var isDirectory: Bool = false
if fileExists(atPath: path, isDirectory: &isDirectory), isDirectory {
// A directory already exists at this path, which is not an error if we have
// `createIntermediates == true`.
break
}
// A file (not a directory) exists at the given path or the file creation failed and the item
// at this path has been deleted before the call to `fileExists`. Throw the original error.
fallthrough
default:
throw CocoaError.errorWithFilePath(path, win32: errorCode, reading: false)
}
}

try _createDirectoryRecursively(at: path)
if let attributes {
try? fileManager.setAttributes(attributes, ofItemAtPath: path)
}
} else {
try path.withNTPathRepresentation { pwszPath in
guard CreateDirectoryW(pwszPath, &saAttributes) else {
Expand Down Expand Up @@ -509,14 +497,9 @@ extension _FileManagerImpl {
// This is solely to minimize the number of allocations and number of bytes allocated versus starting with a hardcoded value like MAX_PATH.
// We should NOT early-return if this returns 0, in order to avoid TOCTOU issues.
let dwSize = GetCurrentDirectoryW(0, nil)
let cwd = try? FillNullTerminatedWideStringBuffer(initialSize: dwSize >= 0 ? dwSize : DWORD(MAX_PATH), maxSize: DWORD(Int16.max)) {
return try? FillNullTerminatedWideStringBuffer(initialSize: dwSize >= 0 ? dwSize : DWORD(MAX_PATH), maxSize: DWORD(Int16.max)) {
GetCurrentDirectoryW(DWORD($0.count), $0.baseAddress)
}

// Handle Windows NT object namespace prefix
// The \\?\ prefix is used by Windows NT for device paths and may appear
// in current working directory paths. We strip it to return a standard path.
return cwd?.removingNTPathPrefix()
#else
withUnsafeTemporaryAllocation(of: CChar.self, capacity: FileManager.MAX_PATH_SIZE) { buffer in
guard getcwd(buffer.baseAddress!, FileManager.MAX_PATH_SIZE) != nil else {
Expand Down
19 changes: 10 additions & 9 deletions Sources/FoundationEssentials/FileManager/FileOperations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -366,14 +366,15 @@ enum _FileOperations {
var stack = [(path, false)]
while let (directory, checked) = stack.popLast() {
try directory.withNTPathRepresentation {
let fullpath = String(decodingCString: $0, as: UTF16.self).removingNTPathPrefix()
let ntpath = String(decodingCString: $0, as: UTF16.self)

guard checked || filemanager?._shouldRemoveItemAtPath(ntpath) ?? true else { return }

guard checked || filemanager?._shouldRemoveItemAtPath(fullpath) ?? true else { return }
if RemoveDirectoryW($0) { return }
let dwError: DWORD = GetLastError()
guard dwError == ERROR_DIR_NOT_EMPTY else {
let error = CocoaError.removeFileError(dwError, directory)
guard (filemanager?._shouldProceedAfter(error: error, removingItemAtPath: fullpath) ?? false) else {
guard (filemanager?._shouldProceedAfter(error: error, removingItemAtPath: ntpath) ?? false) else {
throw error
}
return
Expand All @@ -382,29 +383,29 @@ enum _FileOperations {

for entry in _Win32DirectoryContentsSequence(path: directory, appendSlashForDirectory: false, prefix: [directory]) {
try entry.fileNameWithPrefix.withNTPathRepresentation {
let fullpath = String(decodingCString: $0, as: UTF16.self).removingNTPathPrefix()
let ntpath = String(decodingCString: $0, as: UTF16.self)

if entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY == FILE_ATTRIBUTE_DIRECTORY,
entry.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != FILE_ATTRIBUTE_REPARSE_POINT {
if filemanager?._shouldRemoveItemAtPath(fullpath) ?? true {
stack.append((fullpath, true))
if filemanager?._shouldRemoveItemAtPath(ntpath) ?? true {
stack.append((ntpath, true))
}
} else {
if entry.dwFileAttributes & FILE_ATTRIBUTE_READONLY == FILE_ATTRIBUTE_READONLY {
guard SetFileAttributesW($0, entry.dwFileAttributes & ~FILE_ATTRIBUTE_READONLY) else {
throw CocoaError.removeFileError(GetLastError(), entry.fileName)
throw CocoaError.removeFileError(GetLastError(), ntpath)
}
}
if entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY == FILE_ATTRIBUTE_DIRECTORY {
guard filemanager?._shouldRemoveItemAtPath(fullpath) ?? true else { return }
guard filemanager?._shouldRemoveItemAtPath(ntpath) ?? true else { return }
if !RemoveDirectoryW($0) {
let error = CocoaError.removeFileError(GetLastError(), entry.fileName)
guard (filemanager?._shouldProceedAfter(error: error, removingItemAtPath: entry.fileNameWithPrefix) ?? false) else {
throw error
}
}
} else {
guard filemanager?._shouldRemoveItemAtPath(fullpath) ?? true else { return }
guard filemanager?._shouldRemoveItemAtPath(ntpath) ?? true else { return }
if !DeleteFileW($0) {
let error = CocoaError.removeFileError(GetLastError(), entry.fileName)
guard (filemanager?._shouldProceedAfter(error: error, removingItemAtPath: entry.fileNameWithPrefix) ?? false) else {
Expand Down
29 changes: 1 addition & 28 deletions Sources/FoundationEssentials/String/String+Internals.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,7 @@ extension String {
// 2. Canonicalize the path.
// This will add the \\?\ prefix if needed based on the path's length.
var pwszCanonicalPath: LPWSTR?
// Alway add the long path prefix since we don't know if this is a directory.
let flags: ULONG = PATHCCH_ENSURE_IS_EXTENDED_LENGTH_PATH
let flags: ULONG = PATHCCH_ALLOW_LONG_PATHS
let result = PathAllocCanonicalize(pwszFullPath.baseAddress, flags, &pwszCanonicalPath)
if let pwszCanonicalPath {
defer { LocalFree(pwszCanonicalPath) }
Expand All @@ -80,32 +79,6 @@ extension String {
}
}
}
/// Removes the Windows NT prefix for long file paths if present.
/// The \\?\ prefix is used by Windows NT for device paths and may appear
/// in paths returned by system APIs. This method provides a clean way to
/// normalize such paths to standard format.
///
/// - Returns: A string with the NT object namespace prefix removed, or the original string if no prefix is found.
package func removingNTPathPrefix() -> String {
// Use Windows API PathCchStripPrefix for robust prefix handling
return withCString(encodedAs: UTF16.self) { pwszPath in
// Calculate required buffer size (original path length should be sufficient)
let length = wcslen(pwszPath) + 1 // include null terminator

return withUnsafeTemporaryAllocation(of: WCHAR.self, capacity: Int(length)) { buffer in
// Copy the original path to the buffer
_ = buffer.initialize(from: UnsafeBufferPointer(start: pwszPath, count: Int(length)))

// Call PathCchStripPrefix (modifies buffer in place)
_ = PathCchStripPrefix(buffer.baseAddress, buffer.count)

// Return the result regardless of success/failure
// PathCchStripPrefix modifies the buffer in-place and returns S_OK on success
// If it fails, the original path remains unchanged, which is the desired fallback
return String(decodingCString: buffer.baseAddress!, as: UTF16.self)
}
}
}
}
#endif

Expand Down
10 changes: 9 additions & 1 deletion Sources/FoundationEssentials/String/String+Path.swift
Original file line number Diff line number Diff line change
Expand Up @@ -741,7 +741,15 @@ extension String {
guard GetFinalPathNameByHandleW(hFile, $0.baseAddress, dwLength, VOLUME_NAME_DOS) == dwLength - 1 else {
return nil
}
return String(decodingCString: UnsafePointer($0.baseAddress!), as: UTF16.self).removingNTPathPrefix()

let pathBaseAddress: UnsafePointer<WCHAR>
if Array($0.prefix(4)) == Array(#"\\?\"#.utf16) {
// When using `VOLUME_NAME_DOS`, the returned path uses `\\?\`.
pathBaseAddress = UnsafePointer($0.baseAddress!.advanced(by: 4))
} else {
pathBaseAddress = UnsafePointer($0.baseAddress!)
}
return String(decodingCString: pathBaseAddress, as: UTF16.self)
}
}
#else // os(Windows)
Expand Down
4 changes: 0 additions & 4 deletions Sources/FoundationEssentials/WinSDK+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -242,10 +242,6 @@ package var PATHCCH_ALLOW_LONG_PATHS: ULONG {
ULONG(WinSDK.PATHCCH_ALLOW_LONG_PATHS.rawValue)
}

package var PATHCCH_ENSURE_IS_EXTENDED_LENGTH_PATH: ULONG {
ULONG(WinSDK.PATHCCH_ENSURE_IS_EXTENDED_LENGTH_PATH.rawValue)
}

package var RRF_RT_REG_SZ: DWORD {
DWORD(WinSDK.RRF_RT_REG_SZ)
}
Expand Down
20 changes: 8 additions & 12 deletions Tests/FoundationEssentialsTests/FileManager/FileManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1133,12 +1133,6 @@ private struct FileManagerTests {
let fileName = UUID().uuidString
let cwd = fileManager.currentDirectoryPath

#expect(fileManager.changeCurrentDirectoryPath(cwd))
#expect(cwd == fileManager.currentDirectoryPath)

let nearLimitDir = cwd + "/" + String(repeating: "A", count: 255 - cwd.count)
#expect(throws: Never.self) { try fileManager.createDirectory(at: URL(fileURLWithPath: nearLimitDir), withIntermediateDirectories: false) }

#expect(fileManager.createFile(atPath: dirName + "/" + fileName, contents: nil))

let dirURL = URL(filePath: dirName, directoryHint: .checkFileSystem)
Expand Down Expand Up @@ -1177,6 +1171,12 @@ private struct FileManagerTests {

#expect(throws: Never.self) { try fileManager.createDirectory(at: URL(fileURLWithPath: dirName + "/" + "subdir1"), withIntermediateDirectories: false) }

// SHCreateDirectoryExW's path argument is limited to 248 characters, and the \\?\ prefix doesn't help.
// https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/nf-shlobj_core-shcreatedirectoryexw
#expect(throws: (any Error).self) {
try fileManager.createDirectory(at: URL(fileURLWithPath: dirName + "/" + "subdir2" + "/" + "subdir3"), withIntermediateDirectories: true)
}

// SetCurrentDirectory seems to be limited to MAX_PATH unconditionally, counter to the documentation.
// https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-setcurrentdirectory
// https://github.com/MicrosoftDocs/feedback/issues/1441
Expand All @@ -1195,12 +1195,8 @@ private struct FileManagerTests {

#expect((cwd + "/" + dirName + "/" + "lnk").resolvingSymlinksInPath == (cwd + "/" + dirName + "/" + fileName).resolvingSymlinksInPath)

#expect(throws: Never.self) {
try fileManager.createDirectory(at: URL(fileURLWithPath: dirName + "/" + "subdir2" + "/" + "subdir3"), withIntermediateDirectories: true)
}
#expect(throws: Never.self) { try fileManager.createDirectory(at: URL(fileURLWithPath: dirName + "/" + "subdir4"), withIntermediateDirectories: false) }
#expect(throws: Never.self) { try fileManager.createDirectory(at: URL(fileURLWithPath: dirName + "/" + "subdir4" + "/" + "subdir5"), withIntermediateDirectories: false) }

#expect(throws: Never.self) { try fileManager.createDirectory(at: URL(fileURLWithPath: dirName + "/" + "subdir2"), withIntermediateDirectories: false) }
#expect(throws: Never.self) { try fileManager.createDirectory(at: URL(fileURLWithPath: dirName + "/" + "subdir2" + "/" + "subdir3"), withIntermediateDirectories: false) }
#expect(throws: Never.self) { try Data().write(to: URL(fileURLWithPath: dirName + "/" + "subdir2" + "/" + "subdir3" + "/" + "somefile")) }
#expect(throws: Never.self) { try Data().write(to: URL(fileURLWithPath: dirName + "/" + "subdir2" + "/" + "subdir3" + "/" + "somefile2")) }
#expect(throws: Never.self) { try fileManager.moveItem(atPath: dirName + "/" + "subdir2" + "/" + "subdir3" + "/" + "somefile2", toPath: dirName + "/" + "subdir2" + "/" + "subdir3" + "/" + "somefile3") }
Expand Down
75 changes: 0 additions & 75 deletions Tests/FoundationEssentialsTests/String/StringNTPathTests.swift

This file was deleted.