-
Notifications
You must be signed in to change notification settings - Fork 131
Resolves #505 - Fix handling for Windows long paths #506
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,7 @@ | ||
/* | ||
This source file is part of the Swift.org open source project | ||
|
||
Copyright (c) 2014 - 2018 Apple Inc. and the Swift project authors | ||
Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors | ||
Licensed under Apache License v2.0 with Runtime Library Exception | ||
|
||
See http://swift.org/LICENSE.txt for license information | ||
|
@@ -32,6 +32,7 @@ import var Foundation.NSLocalizedDescriptionKey | |
/// - Removing `.` path components | ||
/// - Removing any trailing path separator | ||
/// - Removing any redundant path separators | ||
/// - Converting the disk designator to uppercase (Windows) i.e. c:\ to C:\ | ||
/// | ||
/// This string manipulation may change the meaning of a path if any of the | ||
/// path components are symbolic links on disk. However, the file system is | ||
|
@@ -506,21 +507,30 @@ private struct WindowsPath: Path, Sendable { | |
var components: [String] { | ||
let normalized: UnsafePointer<Int8> = string.fileSystemRepresentation | ||
defer { normalized.deallocate() } | ||
|
||
return String(cString: normalized).components(separatedBy: "\\").filter { !$0.isEmpty } | ||
// Remove prefix from the components, allowing for comparison across normalized paths. | ||
var prefixStrippedPath = PathCchStripPrefix(String(cString: normalized)) | ||
// The '\\.\'' prefix is not removed by PathCchStripPrefix do this manually. | ||
if prefixStrippedPath.starts(with: #"\\.\"#) { | ||
prefixStrippedPath = String(prefixStrippedPath.dropFirst(4)) | ||
} | ||
return prefixStrippedPath.components(separatedBy: #"\"#).filter { !$0.isEmpty } | ||
} | ||
|
||
var parentDirectory: Self { | ||
return self == .root ? self : Self(string: dirname) | ||
} | ||
|
||
init(string: String) { | ||
if string.first?.isASCII ?? false, string.first?.isLetter ?? false, string.first?.isLowercase ?? false, | ||
string.count > 1, string[string.index(string.startIndex, offsetBy: 1)] == ":" | ||
let noPrefixPath = PathCchStripPrefix(string) | ||
let prefix = string.replacingOccurrences(of: noPrefixPath, with: "") // Just the prefix or empty | ||
|
||
// Perform drive designator normalization i.e. 'c:\' to 'C:\' on string. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's concerning that the Win32 APIs don't do this. I wonder if we want to move this part into @compnerd Any thoughts on drive letter casing? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we're canonicalising here so that plain string comparison works, we should use uppercase for device names/drive letters, IMO. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Paths are case insensitive, and I don't know of any win32 API that would change the drive letter. I don't know if there is a canonical spelling for the drive letter. |
||
if noPrefixPath.first?.isASCII ?? false, noPrefixPath.first?.isLetter ?? false, noPrefixPath.first?.isLowercase ?? false, | ||
noPrefixPath.count > 1, noPrefixPath[noPrefixPath.index(noPrefixPath.startIndex, offsetBy: 1)] == ":" | ||
{ | ||
self.string = "\(string.first!.uppercased())\(string.dropFirst(1))" | ||
self.string = "\(prefix)\(noPrefixPath.first!.uppercased())\(noPrefixPath.dropFirst(1))" | ||
} else { | ||
self.string = string | ||
self.string = prefix + noPrefixPath | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder if this complexity is worth it - we could just pay the small penalty and always follow the canonicalization path irrespective of if |
||
} | ||
|
||
|
@@ -536,7 +546,13 @@ private struct WindowsPath: Path, Sendable { | |
if !Self.isAbsolutePath(realpath) { | ||
throw PathValidationError.invalidAbsolutePath(path) | ||
} | ||
self.init(string: realpath) | ||
do { | ||
let canonicalizedPath = try canonicalPathRepresentation(realpath) | ||
let normalizedPath = PathCchRemoveBackslash(canonicalizedPath) // AbsolutePath states paths have no trailing separator. | ||
self.init(string: normalizedPath) | ||
} catch { | ||
throw PathValidationError.invalidAbsolutePath("\(path): \(error)") | ||
} | ||
} | ||
|
||
init(validatingRelativePath path: String) throws { | ||
|
@@ -554,12 +570,20 @@ private struct WindowsPath: Path, Sendable { | |
|
||
func suffix(withDot: Bool) -> String? { | ||
return self.string.withCString(encodedAs: UTF16.self) { | ||
if let pointer = PathFindExtensionW($0) { | ||
let substring = String(decodingCString: pointer, as: UTF16.self) | ||
guard substring.length > 0 else { return nil } | ||
return withDot ? substring : String(substring.dropFirst(1)) | ||
} | ||
return nil | ||
if let dotPointer = PathFindExtensionW($0) { | ||
// If the dotPointer is the same as the full path, there are no components before | ||
// the suffix and therefore there is no suffix. | ||
if dotPointer == $0 { | ||
return nil | ||
} | ||
let substring = String(decodingCString: dotPointer, as: UTF16.self) | ||
// Substring must have a dot and one more character to be considered a suffix | ||
guard substring.length > 1 else { | ||
return nil | ||
} | ||
return withDot ? substring : String(substring.dropFirst(1)) | ||
} | ||
return nil | ||
} | ||
} | ||
|
||
|
@@ -585,6 +609,111 @@ private struct WindowsPath: Path, Sendable { | |
return Self(string: String(decodingCString: result!, as: UTF16.self)) | ||
} | ||
} | ||
|
||
fileprivate func HRESULT_CODE(_ hr: HRESULT) -> DWORD { | ||
DWORD(hr) & 0xFFFF | ||
} | ||
|
||
@inline(__always) | ||
fileprivate func HRESULT_FACILITY(_ hr: HRESULT) -> DWORD { | ||
DWORD(hr << 16) & 0x1FFF | ||
} | ||
|
||
@inline(__always) | ||
fileprivate func SUCCEEDED(_ hr: HRESULT) -> Bool { | ||
hr >= 0 | ||
} | ||
|
||
// This is a non-standard extension to the Windows SDK that allows us to convert | ||
// an HRESULT to a Win32 error code. | ||
@inline(__always) | ||
fileprivate func WIN32_FROM_HRESULT(_ hr: HRESULT) -> DWORD { | ||
if SUCCEEDED(hr) { return DWORD(ERROR_SUCCESS) } | ||
if HRESULT_FACILITY(hr) == FACILITY_WIN32 { | ||
return HRESULT_CODE(hr) | ||
} | ||
return DWORD(hr) | ||
} | ||
|
||
/// Create a canonicalized path representation for Windows. | ||
/// Returns a potentially `\\?\`-prefixed version of the path, | ||
/// to ensure long paths greater than MAX_PATH (260) characters are handled correctly. | ||
/// | ||
/// - seealso: https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation | ||
fileprivate func canonicalPathRepresentation(_ path: String) throws -> String { | ||
return try path.withCString(encodedAs: UTF16.self) { pwszPlatformPath in | ||
// 1. Normalize the path first. | ||
// Contrary to the documentation, this works on long paths independently | ||
// of the registry or process setting to enable long paths (but it will also | ||
// not add the \\?\ prefix required by other functions under these conditions). | ||
let dwLength: DWORD = GetFullPathNameW(pwszPlatformPath, 0, nil, nil) | ||
|
||
return try withUnsafeTemporaryAllocation(of: WCHAR.self, capacity: Int(dwLength)) { pwszFullPath in | ||
guard (1 ..< dwLength).contains(GetFullPathNameW(pwszPlatformPath, DWORD(pwszFullPath.count), pwszFullPath.baseAddress, nil)) else { | ||
throw Win32Error(GetLastError()) | ||
} | ||
// 1.5 Leave \\.\ prefixed paths alone since device paths are already an exact representation and PathCchCanonicalizeEx will mangle these. | ||
if pwszFullPath.count >= 4 { | ||
if let base = pwszFullPath.baseAddress, | ||
Comment on lines
+656
to
+657
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Style nit: I would've compressed this to: if pwszFullPath.count >= 4, let base = pwszFullPath.baseAddress, ... |
||
base[0] == UInt8(ascii: "\\"), | ||
base[1] == UInt8(ascii: "\\"), | ||
base[2] == UInt8(ascii: "."), | ||
base[3] == UInt8(ascii: "\\") | ||
{ | ||
return String(decodingCString: base, as: UTF16.self) | ||
} | ||
} | ||
// 2. Canonicalize the path. | ||
// This will add the \\?\ prefix if needed based on the path's length. | ||
var pwszCanonicalPath: LPWSTR? | ||
let flags: ULONG = numericCast(PATHCCH_ALLOW_LONG_PATHS.rawValue) | ||
let result = PathAllocCanonicalize(pwszFullPath.baseAddress, flags, &pwszCanonicalPath) | ||
if let pwszCanonicalPath { | ||
defer { LocalFree(pwszCanonicalPath) } | ||
if result == S_OK { | ||
// 3. Perform the operation on the normalized path. | ||
return String(decodingCString: pwszCanonicalPath, as: UTF16.self) | ||
} | ||
} | ||
throw Win32Error(WIN32_FROM_HRESULT(result)) | ||
} | ||
} | ||
} | ||
|
||
/// Removes the "\\?\" prefix, if present, from a file path. When this function returns successfully, | ||
/// the same path string will have the prefix removed,if the prefix was present. | ||
/// If no prefix was present,the string will be unchanged. | ||
fileprivate func PathCchStripPrefix(_ path: String) -> String { | ||
return path.withCString(encodedAs: UTF16.self) { cStringPtr in | ||
withUnsafeTemporaryAllocation(of: WCHAR.self, capacity: path.utf16.count + 1) { buffer in | ||
buffer.initialize(from: UnsafeBufferPointer(start: cStringPtr, count: path.utf16.count + 1)) | ||
let result = PathCchStripPrefix(buffer.baseAddress!, buffer.count) | ||
if result == S_OK { | ||
return String(decodingCString: buffer.baseAddress!, as: UTF16.self) | ||
} | ||
return path | ||
} | ||
} | ||
} | ||
|
||
/// Remove a trailing backslash from a path if the following conditions | ||
/// are true: | ||
/// * Path is not a root path | ||
/// * Pash has a trailing backslash | ||
/// If conditions are not met then the string is returned unchanged. | ||
fileprivate func PathCchRemoveBackslash(_ path: String) -> String { | ||
return path.withCString(encodedAs: UTF16.self) { cStringPtr in | ||
return withUnsafeTemporaryAllocation(of: WCHAR.self, capacity: path.utf16.count + 1) { buffer in | ||
buffer.initialize(from: UnsafeBufferPointer(start: cStringPtr, count: path.utf16.count + 1)) | ||
let result = PathCchRemoveBackslash(buffer.baseAddress!, path.utf16.count + 1) | ||
if result == S_OK { | ||
return String(decodingCString: buffer.baseAddress!, as: UTF16.self) | ||
} | ||
return path | ||
} | ||
return path | ||
} | ||
} | ||
#else | ||
private struct UNIXPath: Path, Sendable { | ||
let string: String | ||
|
@@ -966,7 +1095,8 @@ extension AbsolutePath { | |
} | ||
} | ||
|
||
assert(AbsolutePath(base, result) == self) | ||
assert(AbsolutePath(base, result) == self, "base:\(base) result:\(result) self: \(self)") | ||
|
||
return result | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
/* | ||
This source file is part of the Swift.org open source project | ||
|
||
Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors | ||
Licensed under Apache License v2.0 with Runtime Library Exception | ||
|
||
See http://swift.org/LICENSE.txt for license information | ||
See http://swift.org/CONTRIBUTORS.txt for Swift project authors | ||
*/ | ||
|
||
#if os(Windows) | ||
public import WinSDK | ||
import Foundation | ||
|
||
public struct Win32Error: Error, CustomStringConvertible { | ||
public let error: DWORD | ||
|
||
public init(_ error: DWORD) { | ||
self.error = error | ||
} | ||
|
||
public var description: String { | ||
let flags: DWORD = DWORD(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS) | ||
var buffer: UnsafeMutablePointer<WCHAR>? | ||
let length: DWORD = withUnsafeMutablePointer(to: &buffer) { | ||
$0.withMemoryRebound(to: WCHAR.self, capacity: 2) { | ||
FormatMessageW(flags, nil, error, 0, $0, 0, nil) | ||
} | ||
} | ||
guard let buffer, length > 0 else { | ||
return "Win32 Error Code \(error)" | ||
} | ||
defer { LocalFree(buffer) } | ||
return String(decodingCString: buffer, as: UTF16.self).trimmingCharacters(in: .whitespacesAndNewlines) | ||
} | ||
} | ||
#endif |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What about the
\\?\
prefixed? That's the more commonly used form.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
var prefixStrippedPath = Self.stripRawPathPrefix(String(cString: normalized))
That will remove the \?\ prefix
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There are a variety of prefixes:
\\?\
- root local path\\.\
- device local path\??\
- NT Object Path\\Device\
- NT Device PathThe interesting thing is that for a number of these, it is not possible to construct a win32 representation of the path, and the prefix should not be stripped.
Consider something like:
\\?\Volume{2b10d654-f5dd-4597-bd8b-480ddd40bcbc}\Users\compnerd\SourceCache\swift-project\swift-tools-support-core
.How would we handle this path? It actually may not be a mapped volume, so there is no associated drive letter to reference it. However, by using the absolute root local path, we can reference the file content routing through the MUP driver.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You are correct that there is so many more situations that the current implementation of Path, that are not covered. I believe this change based on the test cases in place gets us a bit closer, so at least we can handle longer paths i..e. \?\C:<long-path>, but lots more work is required.
There are many other path nuances to deal with as you have mentioned.