Skip to content

Commit 90953df

Browse files
Transparently add the \\?\ prefix to Win32 calls for extended length path handling (#1257)
On Windows, there is a built-in maximum path limitation of 260 characters under most conditions. This can be extended to 32767 characters under either of the following two conditions: - Adding the longPathAware attribute to the executable's manifest AND enabling the LongPathsEnabled system-wide registry key or group policy. - Ensuring fully qualified paths passed to Win32 APIs are prefixed with \?\ Unfortunately, the former is not realistic for the Swift ecosystem, since it requires developers to have awareness of this specific Windows limitation, AND set longPathAware in their apps' manifest AND expect end users of those apps to change their system configuration. Instead, this patch transparently prefixes all eligible paths in calls to Win32 APIs with the \?\ prefix to allow them to work with paths longer than 260 characters without requiring the caller of Foundation to manually prefix the paths. See https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation for more info.
1 parent a3e9949 commit 90953df

File tree

6 files changed

+129
-39
lines changed

6 files changed

+129
-39
lines changed

Sources/FoundationEssentials/Data/Data+Writing.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ private func createTemporaryFile(at destinationPath: String, inPath: PathOrURL,
171171
guard _mktemp_s(templateFileSystemRep, strlen(templateFileSystemRep) + 1) == 0 else {
172172
throw CocoaError.errorWithFilePath(inPath, errno: errno, reading: false, variant: variant)
173173
}
174-
let fd = String(cString: templateFileSystemRep).withCString(encodedAs: UTF16.self) {
174+
let fd = try String(cString: templateFileSystemRep).withNTPathRepresentation {
175175
openFileDescriptorProtected(path: $0, flags: _O_BINARY | _O_CREAT | _O_EXCL | _O_RDWR, options: options)
176176
}
177177
#else

Sources/FoundationEssentials/FileManager/FileManager+Directories.swift

+1-31
Original file line numberDiff line numberDiff line change
@@ -257,36 +257,6 @@ extension _FileManagerImpl {
257257
try fileManager.createDirectory(atPath: path, withIntermediateDirectories: createIntermediates, attributes: attributes)
258258
}
259259

260-
#if os(Windows)
261-
/// If `path` is absolute, this is the same as `path.withNTPathRepresentation`.
262-
/// If `path` is relative, this creates an absolute path of `path` relative to `currentDirectoryPath` and runs
263-
/// `body` with that path.
264-
private func withAbsoluteNTPathRepresentation<Result>(
265-
of path: String,
266-
_ body: (UnsafePointer<WCHAR>) throws -> Result
267-
) throws -> Result {
268-
try path.withNTPathRepresentation { pwszPath in
269-
if !PathIsRelativeW(pwszPath) {
270-
// We already have an absolute path. Nothing to do
271-
return try body(pwszPath)
272-
}
273-
guard let currentDirectoryPath else {
274-
preconditionFailure("We should always have a current directory on Windows")
275-
}
276-
277-
// We have a relateive path. Make it absolute.
278-
let absoluteUrl = URL(
279-
filePath: path,
280-
directoryHint: .isDirectory,
281-
relativeTo: URL(filePath: currentDirectoryPath, directoryHint: .isDirectory)
282-
)
283-
return try absoluteUrl.path.withNTPathRepresentation { pwszPath in
284-
return try body(pwszPath)
285-
}
286-
}
287-
}
288-
#endif
289-
290260
func createDirectory(
291261
atPath path: String,
292262
withIntermediateDirectories createIntermediates: Bool,
@@ -301,7 +271,7 @@ extension _FileManagerImpl {
301271
if createIntermediates {
302272
// `SHCreateDirectoryExW` requires an absolute path while `CreateDirectoryW` works based on the current working
303273
// directory.
304-
try withAbsoluteNTPathRepresentation(of: path) { pwszPath in
274+
try path.withNTPathRepresentation { pwszPath in
305275
let errorCode = SHCreateDirectoryExW(nil, pwszPath, &saAttributes)
306276
guard let errorCode = DWORD(exactly: errorCode) else {
307277
// `SHCreateDirectoryExW` returns `Int` but all error codes are defined in terms of `DWORD`, aka

Sources/FoundationEssentials/FileManager/FileManager+SymbolicLinks.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ extension _FileManagerImpl {
6464

6565
try path.withNTPathRepresentation { lpSymlinkFileName in
6666
try destPath.withFileSystemRepresentation {
67-
try String(cString: $0!).withCString(encodedAs: UTF16.self) { lpTargetFileName in
67+
try String(cString: $0!).withNTPathRepresentation(relative: true) { lpTargetFileName in
6868
if CreateSymbolicLinkW(lpSymlinkFileName, lpTargetFileName, SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE | (bIsDirectory ? SYMBOLIC_LINK_FLAG_DIRECTORY : 0)) == 0 {
6969
throw CocoaError.errorWithFilePath(path, win32: GetLastError(), reading: false)
7070
}

Sources/FoundationEssentials/FileManager/FileOperations.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -829,7 +829,7 @@ enum _FileOperations {
829829
try src.withNTPathRepresentation { pwszSource in
830830
var faAttributes: WIN32_FILE_ATTRIBUTE_DATA = .init()
831831
guard GetFileAttributesExW(pwszSource, GetFileExInfoStandard, &faAttributes) else {
832-
throw CocoaError.errorWithFilePath(.fileReadNoSuchFile, src, variant: bCopyFile ? "Copy" : "Link", source: src, destination: dst)
832+
throw CocoaError.errorWithFilePath(src, win32: GetLastError(), reading: true, variant: bCopyFile ? "Copy" : "Link", source: src, destination: dst)
833833
}
834834

835835
guard delegate.shouldPerformOnItemAtPath(src, to: dst) else { return }

Sources/FoundationEssentials/String/String+Internals.swift

+37-5
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,12 @@ import Darwin
2323
import WinSDK
2424

2525
extension String {
26-
package func withNTPathRepresentation<Result>(_ body: (UnsafePointer<WCHAR>) throws -> Result) throws -> Result {
26+
/// Invokes `body` with a resolved and potentially `\\?\`-prefixed version of the pointee,
27+
/// to ensure long paths greater than MAX_PATH (260) characters are handled correctly.
28+
///
29+
/// - parameter relative: Returns the original path without transforming through GetFullPathNameW + PathCchCanonicalizeEx, if the path is relative.
30+
/// - seealso: https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation
31+
package func withNTPathRepresentation<Result>(relative: Bool = false, _ body: (UnsafePointer<WCHAR>) throws -> Result) throws -> Result {
2732
guard !isEmpty else {
2833
throw CocoaError.errorWithFilePath(.fileReadInvalidFileName, "")
2934
}
@@ -35,15 +40,42 @@ extension String {
3540
// leading slash indicates a rooted path on the drive for the current
3641
// working directory.
3742
return try Substring(self.utf8.dropFirst(bLeadingSlash ? 1 : 0)).withCString(encodedAs: UTF16.self) { pwszPath in
43+
if relative && PathIsRelativeW(pwszPath) {
44+
return try body(pwszPath)
45+
}
46+
3847
// 1. Normalize the path first.
48+
// Contrary to the documentation, this works on long paths independently
49+
// of the registry or process setting to enable long paths (but it will also
50+
// not add the \\?\ prefix required by other functions under these conditions).
3951
let dwLength: DWORD = GetFullPathNameW(pwszPath, 0, nil, nil)
40-
return try withUnsafeTemporaryAllocation(of: WCHAR.self, capacity: Int(dwLength)) {
41-
guard GetFullPathNameW(pwszPath, DWORD($0.count), $0.baseAddress, nil) > 0 else {
52+
return try withUnsafeTemporaryAllocation(of: WCHAR.self, capacity: Int(dwLength)) { pwszFullPath in
53+
guard (1..<dwLength).contains(GetFullPathNameW(pwszPath, DWORD(pwszFullPath.count), pwszFullPath.baseAddress, nil)) else {
4254
throw CocoaError.errorWithFilePath(self, win32: GetLastError(), reading: true)
4355
}
4456

45-
// 2. Perform the operation on the normalized path.
46-
return try body($0.baseAddress!)
57+
// 1.5 Leave \\.\ prefixed paths alone since device paths are already an exact representation and PathCchCanonicalizeEx will mangle these.
58+
if let base = pwszFullPath.baseAddress,
59+
base[0] == UInt16(UInt8._backslash),
60+
base[1] == UInt16(UInt8._backslash),
61+
base[2] == UInt16(UInt8._period),
62+
base[3] == UInt16(UInt8._backslash) {
63+
return try body(base)
64+
}
65+
66+
// 2. Canonicalize the path.
67+
// This will add the \\?\ prefix if needed based on the path's length.
68+
var pwszCanonicalPath: LPWSTR?
69+
let flags: ULONG = PATHCCH_ALLOW_LONG_PATHS
70+
let result = PathAllocCanonicalize(pwszFullPath.baseAddress, flags, &pwszCanonicalPath)
71+
if let pwszCanonicalPath {
72+
defer { LocalFree(pwszCanonicalPath) }
73+
if result == S_OK {
74+
// 3. Perform the operation on the normalized path.
75+
return try body(pwszCanonicalPath)
76+
}
77+
}
78+
throw CocoaError.errorWithFilePath(self, win32: WIN32_FROM_HRESULT(result), reading: true)
4779
}
4880
}
4981
}

Tests/FoundationEssentialsTests/FileManager/FileManagerTests.swift

+88
Original file line numberDiff line numberDiff line change
@@ -1072,4 +1072,92 @@ final class FileManagerTests : XCTestCase {
10721072
XCTAssertEqual($0.contents(atPath: "a\u{301}/test"), data)
10731073
}
10741074
}
1075+
1076+
/// Tests that Foundation can correctly handle "long paths" (paths of 260 to 32767 chacters long) on Windows.
1077+
func testWindowsLongPathSupport() throws {
1078+
#if !os(Windows)
1079+
throw XCTSkip("This test is not applicable for this platform")
1080+
#else
1081+
// Create a directory with the absolute maximum path _component_ length of 255;
1082+
// this will guarantee the full playground path is well over 260 characters.
1083+
// Throw some Unicode in there for good measure, since only wide-character APIs support it.
1084+
let dirName = String(repeating: UUID().uuidString, count: 7) + "你好!"
1085+
XCTAssertEqual(dirName.count, 255)
1086+
XCTAssertEqual(dirName.utf16.count, 255)
1087+
1088+
try FileManagerPlayground {
1089+
Directory(dirName) {
1090+
}
1091+
}.test {
1092+
// Call every function that can call into withNTPathRepresentation with an overlong path and ensure it succeeds.
1093+
let fileName = UUID().uuidString
1094+
let cwd = try XCTUnwrap($0.currentDirectoryPath)
1095+
1096+
XCTAssertTrue($0.createFile(atPath: dirName + "/" + fileName, contents: nil))
1097+
1098+
let dirURL = URL(filePath: dirName, directoryHint: .checkFileSystem)
1099+
XCTAssertTrue(dirURL.hasDirectoryPath)
1100+
1101+
let fileURL = URL(filePath: dirName + "/" + fileName, directoryHint: .checkFileSystem)
1102+
XCTAssertFalse(fileURL.hasDirectoryPath)
1103+
1104+
XCTAssertTrue($0.fileExists(atPath: dirName + "/" + fileName))
1105+
XCTAssertTrue($0.isReadableFile(atPath: dirName + "/" + fileName))
1106+
XCTAssertTrue($0.isWritableFile(atPath: dirName + "/" + fileName))
1107+
1108+
// SHGetFileInfoW is documented to be limited to MAX_PATH, but appears to support long paths anyways (or at least does for SHGFI_EXETYPE).
1109+
// https://learn.microsoft.com/en-us/windows/win32/api/shellapi/nf-shellapi-shgetfileinfow
1110+
XCTAssertNoThrow(try Data().write(to: URL(fileURLWithPath: dirName + "/" + fileName + ".bat")))
1111+
XCTAssertTrue($0.isExecutableFile(atPath: dirName + "/" + fileName + ".bat"))
1112+
XCTAssertFalse($0.isExecutableFile(atPath: dirName + "/" + fileName))
1113+
1114+
XCTAssertNoThrow(try $0.attributesOfItem(atPath: dirName + "/" + fileName))
1115+
XCTAssertNoThrow(try $0.setAttributes([.modificationDate: Date()], ofItemAtPath: dirName + "/" + fileName))
1116+
XCTAssertNoThrow(try $0.attributesOfFileSystem(forPath: dirName + "/" + fileName))
1117+
1118+
XCTAssertNoThrow(try Data(contentsOf: URL(fileURLWithPath: dirName + "/" + fileName)))
1119+
1120+
XCTAssertNoThrow(try Data("hello".utf8).write(to: URL(fileURLWithPath: dirName + "/" + fileName)))
1121+
XCTAssertNoThrow(try Data("hello".utf8).write(to: URL(fileURLWithPath: dirName + "/" + fileName), options: .atomic))
1122+
1123+
XCTAssertNoThrow(try Data("hello".utf8).write(to: URL(fileURLWithPath: dirName + "/" + fileName + ".v2")))
1124+
XCTAssertTrue($0.contentsEqual(atPath: dirName + "/" + fileName, andPath: dirName + "/" + fileName + ".v2"))
1125+
1126+
XCTAssertEqual(try $0.subpathsOfDirectory(atPath: dirName).sorted(), [
1127+
fileName,
1128+
fileName + ".bat",
1129+
fileName + ".v2"
1130+
])
1131+
1132+
XCTAssertNoThrow(try $0.createDirectory(at: URL(fileURLWithPath: dirName + "/" + "subdir1"), withIntermediateDirectories: false))
1133+
1134+
// SHCreateDirectoryExW's path argument is limited to 248 characters, and the \\?\ prefix doesn't help.
1135+
// https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/nf-shlobj_core-shcreatedirectoryexw
1136+
XCTAssertThrowsError(try $0.createDirectory(at: URL(fileURLWithPath: dirName + "/" + "subdir2" + "/" + "subdir3"), withIntermediateDirectories: true))
1137+
1138+
// SetCurrentDirectory seems to be limited to MAX_PATH unconditionally, counter to the documentation.
1139+
// https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-setcurrentdirectory
1140+
// https://github.com/MicrosoftDocs/feedback/issues/1441
1141+
XCTAssertFalse($0.changeCurrentDirectoryPath(dirName + "/" + "subdir1"))
1142+
1143+
XCTAssertNoThrow(try $0.createSymbolicLink(atPath: dirName + "/" + "lnk", withDestinationPath: fileName))
1144+
XCTAssertNoThrow(try $0.createSymbolicLink(atPath: dirName + "/" + "lnk2", withDestinationPath: cwd + "/" + dirName + "/" + fileName))
1145+
XCTAssertEqual(try $0.destinationOfSymbolicLink(atPath: dirName + "/" + "lnk"), fileName)
1146+
XCTAssertEqual(try $0.destinationOfSymbolicLink(atPath: dirName + "/" + "lnk2"), cwd + "\\" + dirName + "\\" + fileName)
1147+
1148+
XCTAssertEqual((cwd + "/" + dirName + "/" + "lnk").resolvingSymlinksInPath, (cwd + "/" + dirName + "/" + fileName).resolvingSymlinksInPath)
1149+
1150+
XCTAssertNoThrow(try $0.createDirectory(at: URL(fileURLWithPath: dirName + "/" + "subdir2"), withIntermediateDirectories: false))
1151+
XCTAssertNoThrow(try $0.createDirectory(at: URL(fileURLWithPath: dirName + "/" + "subdir2" + "/" + "subdir3"), withIntermediateDirectories: false))
1152+
XCTAssertNoThrow(try Data().write(to: URL(fileURLWithPath: dirName + "/" + "subdir2" + "/" + "subdir3" + "/" + "somefile")))
1153+
XCTAssertNoThrow(try Data().write(to: URL(fileURLWithPath: dirName + "/" + "subdir2" + "/" + "subdir3" + "/" + "somefile2")))
1154+
XCTAssertNoThrow(try $0.moveItem(atPath: dirName + "/" + "subdir2" + "/" + "subdir3" + "/" + "somefile2", toPath: dirName + "/" + "subdir2" + "/" + "subdir3" + "/" + "somefile3"))
1155+
XCTAssertNoThrow(try $0.moveItem(atPath: dirName + "/" + "subdir2" + "/" + "subdir3", toPath: dirName + "/" + "subdir2" + "/" + "subdir3.delete"))
1156+
XCTAssertNoThrow(try $0.linkItem(atPath: dirName + "/" + "subdir2" + "/" + "subdir3.delete", toPath: dirName + "/" + "subdir2" + "/" + "subdir3.delete.lnk"))
1157+
XCTAssertNoThrow(try $0.linkItem(atPath: dirName + "/" + "subdir2", toPath: dirName + "/" + "subdir2.lnk"))
1158+
XCTAssertNoThrow(try $0.removeItem(atPath: dirName + "/" + "subdir2" + "/" + "subdir3.delete" + "/" + "somefile3"))
1159+
XCTAssertNoThrow(try $0.removeItem(atPath: dirName + "/" + "subdir2"))
1160+
}
1161+
#endif
1162+
}
10751163
}

0 commit comments

Comments
 (0)