Skip to content

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

Merged
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
3 changes: 2 additions & 1 deletion Sources/TSCBasic/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ add_library(TSCBasic
TerminalController.swift
Thread.swift
Tuple.swift
misc.swift)
misc.swift
Win32Error.swift)

target_compile_options(TSCBasic PUBLIC
# Ignore secure function warnings on Windows.
Expand Down
160 changes: 145 additions & 15 deletions Sources/TSCBasic/Path.swift
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: #"\\.\"#) {
Copy link
Contributor

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.

Copy link
Contributor Author

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

Copy link
Member

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 Path

The 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.

Copy link
Contributor Author

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.

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.
Copy link
Contributor

Choose a reason for hiding this comment

The 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 canonicalPathRepresentation.

@compnerd Any thoughts on drive letter casing?

Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Member

Choose a reason for hiding this comment

The 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
}
Copy link
Member

Choose a reason for hiding this comment

The 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 path.first?.isLowerccase (i.e. just always do the upper case conversion).

}

Expand All @@ -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 {
Expand All @@ -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
}
}

Expand All @@ -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
Copy link
Member

Choose a reason for hiding this comment

The 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
Expand Down Expand Up @@ -966,7 +1095,8 @@ extension AbsolutePath {
}
}

assert(AbsolutePath(base, result) == self)
assert(AbsolutePath(base, result) == self, "base:\(base) result:\(result) self: \(self)")

return result
}

Expand Down
37 changes: 37 additions & 0 deletions Sources/TSCBasic/Win32Error.swift
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
Loading