Skip to content

Commit 1bf3406

Browse files
committed
Fix a corner case where createDirectory would fail on windows
The //?/ prefix needs to be added to directories if the length is > MAX_PATH - 12, however the PATHCCH_ALLOW_LONG_PATHS to PathAllocCanonicalize would only do that if the path was > MAX_PATH. This change uses PATHCCH_ENSURE_IS_EXTENDED_LENGTH_PATH, which forces it on all the time. Just needed to remove the prefix in a few places as it was making it way out into tests as this is suppose to be transparent to the user.
1 parent 574d608 commit 1bf3406

File tree

7 files changed

+124
-12
lines changed

7 files changed

+124
-12
lines changed

Sources/FoundationEssentials/FileManager/FileManager+Directories.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -497,9 +497,14 @@ extension _FileManagerImpl {
497497
// This is solely to minimize the number of allocations and number of bytes allocated versus starting with a hardcoded value like MAX_PATH.
498498
// We should NOT early-return if this returns 0, in order to avoid TOCTOU issues.
499499
let dwSize = GetCurrentDirectoryW(0, nil)
500-
return try? FillNullTerminatedWideStringBuffer(initialSize: dwSize >= 0 ? dwSize : DWORD(MAX_PATH), maxSize: DWORD(Int16.max)) {
500+
let cwd = try? FillNullTerminatedWideStringBuffer(initialSize: dwSize >= 0 ? dwSize : DWORD(MAX_PATH), maxSize: DWORD(Int16.max)) {
501501
GetCurrentDirectoryW(DWORD($0.count), $0.baseAddress)
502502
}
503+
504+
// Handle Windows NT object namespace prefix
505+
// The \\??\ prefix is used by Windows NT for device paths and may appear
506+
// in current working directory paths. We strip it to return a standard path.
507+
return cwd?.removingNTObjectNamespacePrefix()
503508
#else
504509
withUnsafeTemporaryAllocation(of: CChar.self, capacity: FileManager.MAX_PATH_SIZE) { buffer in
505510
guard getcwd(buffer.baseAddress!, FileManager.MAX_PATH_SIZE) != nil else {

Sources/FoundationEssentials/FileManager/FileOperations.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,9 @@ extension FileManager {
139139
fileprivate func _shouldRemoveItemAtPath(_ path: String) -> Bool {
140140
var delegateResponse: Bool?
141141
if let delegate = self.safeDelegate {
142+
#if os(Windows)
143+
let path = path.removingNTObjectNamespacePrefix()
144+
#endif
142145
#if FOUNDATION_FRAMEWORK
143146
delegateResponse = delegate.fileManager?(self, shouldRemoveItemAt: URL(fileURLWithPath: path))
144147

@@ -393,7 +396,7 @@ enum _FileOperations {
393396
} else {
394397
if entry.dwFileAttributes & FILE_ATTRIBUTE_READONLY == FILE_ATTRIBUTE_READONLY {
395398
guard SetFileAttributesW($0, entry.dwFileAttributes & ~FILE_ATTRIBUTE_READONLY) else {
396-
throw CocoaError.removeFileError(GetLastError(), ntpath)
399+
throw CocoaError.removeFileError(GetLastError(), entry.fileName)
397400
}
398401
}
399402
if entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY == FILE_ATTRIBUTE_DIRECTORY {

Sources/FoundationEssentials/String/String+Internals.swift

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ extension String {
6666
// 2. Canonicalize the path.
6767
// This will add the \\?\ prefix if needed based on the path's length.
6868
var pwszCanonicalPath: LPWSTR?
69-
let flags: ULONG = PATHCCH_ALLOW_LONG_PATHS
69+
// Alway add the long path prefix since we don't know if this is a directory.
70+
let flags: ULONG = PATHCCH_ENSURE_IS_EXTENDED_LENGTH_PATH
7071
let result = PathAllocCanonicalize(pwszFullPath.baseAddress, flags, &pwszCanonicalPath)
7172
if let pwszCanonicalPath {
7273
defer { LocalFree(pwszCanonicalPath) }
@@ -79,6 +80,32 @@ extension String {
7980
}
8081
}
8182
}
83+
/// Removes the Windows NT object namespace prefix if present.
84+
/// The \\??\ prefix is used by Windows NT for device paths and may appear
85+
/// in paths returned by system APIs. This method provides a clean way to
86+
/// normalize such paths to standard format.
87+
///
88+
/// - Returns: A string with the NT object namespace prefix removed, or the original string if no prefix is found.
89+
package func removingNTObjectNamespacePrefix() -> String {
90+
// Use Windows API PathCchStripPrefix for robust prefix handling
91+
return withCString(encodedAs: UTF16.self) { pwszPath in
92+
// Calculate required buffer size (original path length should be sufficient)
93+
let length = wcslen(pwszPath) + 1 // include null terminator
94+
95+
return withUnsafeTemporaryAllocation(of: WCHAR.self, capacity: Int(length)) { buffer in
96+
// Copy the original path to the buffer
97+
_ = buffer.initialize(from: UnsafeBufferPointer(start: pwszPath, count: Int(length)))
98+
99+
// Call PathCchStripPrefix (modifies buffer in place)
100+
_ = PathCchStripPrefix(buffer.baseAddress, buffer.count)
101+
102+
// Return the result regardless of success/failure
103+
// PathCchStripPrefix modifies the buffer in-place and returns S_OK on success
104+
// If it fails, the original path remains unchanged, which is the desired fallback
105+
return String(decodingCString: buffer.baseAddress!, as: UTF16.self)
106+
}
107+
}
108+
}
82109
}
83110
#endif
84111

Sources/FoundationEssentials/String/String+Path.swift

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -741,15 +741,7 @@ extension String {
741741
guard GetFinalPathNameByHandleW(hFile, $0.baseAddress, dwLength, VOLUME_NAME_DOS) == dwLength - 1 else {
742742
return nil
743743
}
744-
745-
let pathBaseAddress: UnsafePointer<WCHAR>
746-
if Array($0.prefix(4)) == Array(#"\\?\"#.utf16) {
747-
// When using `VOLUME_NAME_DOS`, the returned path uses `\\?\`.
748-
pathBaseAddress = UnsafePointer($0.baseAddress!.advanced(by: 4))
749-
} else {
750-
pathBaseAddress = UnsafePointer($0.baseAddress!)
751-
}
752-
return String(decodingCString: pathBaseAddress, as: UTF16.self)
744+
return String(decodingCString: UnsafePointer($0.baseAddress!), as: UTF16.self).removingNTObjectNamespacePrefix()
753745
}
754746
}
755747
#else // os(Windows)

Sources/FoundationEssentials/WinSDK+Extensions.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,10 @@ package var PATHCCH_ALLOW_LONG_PATHS: ULONG {
229229
ULONG(WinSDK.PATHCCH_ALLOW_LONG_PATHS.rawValue)
230230
}
231231

232+
package var PATHCCH_ENSURE_IS_EXTENDED_LENGTH_PATH: ULONG {
233+
ULONG(WinSDK.PATHCCH_ENSURE_IS_EXTENDED_LENGTH_PATH.rawValue)
234+
}
235+
232236
package var RRF_RT_REG_SZ: DWORD {
233237
DWORD(WinSDK.RRF_RT_REG_SZ)
234238
}

Tests/FoundationEssentialsTests/FileManager/FileManagerTests.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1133,6 +1133,12 @@ private struct FileManagerTests {
11331133
let fileName = UUID().uuidString
11341134
let cwd = fileManager.currentDirectoryPath
11351135

1136+
#expect(fileManager.changeCurrentDirectoryPath(cwd))
1137+
#expect(cwd == fileManager.currentDirectoryPath)
1138+
1139+
let nearLimitDir = cwd + "/" + String(repeating: "A", count: 255 - cwd.count)
1140+
#expect(throws: Never.self) { try fileManager.createDirectory(at: URL(fileURLWithPath: nearLimitDir), withIntermediateDirectories: false) }
1141+
11361142
#expect(fileManager.createFile(atPath: dirName + "/" + fileName, contents: nil))
11371143

11381144
let dirURL = URL(filePath: dirName, directoryHint: .checkFileSystem)
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2023 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
#if os(Windows)
14+
15+
import Testing
16+
17+
#if FOUNDATION_FRAMEWORK
18+
import Foundation
19+
#else
20+
import FoundationEssentials
21+
#endif
22+
23+
@Suite("String NT Path Tests")
24+
struct StringNTPathTests {
25+
26+
@Test("Normal drive path, no prefix")
27+
func noPrefix() {
28+
let path = "C:\\Windows\\System32"
29+
#expect(path.removingNTObjectNamespacePrefix() == "C:\\Windows\\System32")
30+
}
31+
32+
@Test("Extended-length path prefix (\\\\?\\)")
33+
func extendedPrefix() {
34+
let path = #"\\?\C:\Windows\System32"#
35+
#expect(path.removingNTObjectNamespacePrefix() == "C:\\Windows\\System32")
36+
}
37+
38+
@Test("UNC path with extended prefix (\\\\?\\UNC\\)")
39+
func uncExtendedPrefix() {
40+
let path = #"\\?\UNC\Server\Share\Folder"#
41+
#expect(path.removingNTObjectNamespacePrefix() == #"\\Server\Share\Folder"#)
42+
}
43+
44+
@Test("UNC path without extended prefix")
45+
func uncNormal() {
46+
let path = #"\\Server\Share\Folder"#
47+
#expect(path.removingNTObjectNamespacePrefix() == #"\\Server\Share\Folder"#)
48+
}
49+
50+
@Test("Empty string should stay empty")
51+
func emptyString() {
52+
let path = ""
53+
#expect(path.removingNTObjectNamespacePrefix() == "")
54+
}
55+
56+
@Test("Path with only prefix should return empty")
57+
func prefixOnly() {
58+
let path = #"\\?\C:\"#
59+
#expect(path.removingNTObjectNamespacePrefix() == #"C:\"#)
60+
}
61+
62+
@Test("Path longer than MAX_PATH (260 chars)")
63+
func longPathBeyondMaxPath() {
64+
// Create a folder name repeated to exceed 260 chars
65+
let longComponent = String(repeating: "A", count: 280)
66+
let rawPath = #"\\?\C:\Test\"# + longComponent
67+
68+
// After stripping, it should drop the \\?\ prefix but keep the full long component
69+
let expected = "C:\\Test\\" + longComponent
70+
71+
let stripped = rawPath.removingNTObjectNamespacePrefix()
72+
#expect(stripped == expected)
73+
}
74+
}
75+
#endif

0 commit comments

Comments
 (0)