Skip to content

Commit eb4c83d

Browse files
authored
Resolves #505 - Fix handling for Windows long paths (#506)
* Resolves #505 - Fix handling for Windows long paths PR#369 - #369 Caused the majority of tests on Windows to fail, as normalization of RelativePath was removed. This change also removed the call to 'PathAllocCanonicalize' for AbsolutePath which had the long file flag 'PATHCCH_ALLOW_LONG_PATHS'. Reintroducing canonicalization of AbsolutePath path representation to handle long paths. * Update tests dealing with RelativePath to match implementation. * Canonicalize the path representation for AbsolutePath which also allows for long path '\\?\' prefix addition when path > 260 in length. * Strip trailing backslash on string representation of AbsolutePath to match definition. Only strips for non root paths. * Add helper functions: - removeTrailingBackslash - stripPrefix - canonicalPathRepresentation * Add Windows API Error helpers * Add long path tests into each test case * Fix up .suffix. '.' has no suffix * Update copyright dates * Make PathCchStripPrefix and PathCchRemoveBackslash use temporary buffer. - Move PathCchStripPrefix and PathCchRemoveBackslash to use a mutable temporary buffer. - Could not use buffer.withMemoryRebound and getCstring() as on Windows this seem to produce corrupt data. - Add more tests for unParsed '\\?\' and device '\\.\' paths - Remove the PATHCCH_CANONICALIZE_SLASHES flag as it is not needed. - Add Win32Error.swift to CMakeLists
1 parent e8fbc8b commit eb4c83d

File tree

4 files changed

+793
-67
lines changed

4 files changed

+793
-67
lines changed

Sources/TSCBasic/CMakeLists.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ add_library(TSCBasic
4949
TerminalController.swift
5050
Thread.swift
5151
Tuple.swift
52-
misc.swift)
52+
misc.swift
53+
Win32Error.swift)
5354

5455
target_compile_options(TSCBasic PUBLIC
5556
# Ignore secure function warnings on Windows.

Sources/TSCBasic/Path.swift

Lines changed: 145 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

4-
Copyright (c) 2014 - 2018 Apple Inc. and the Swift project authors
4+
Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66

77
See http://swift.org/LICENSE.txt for license information
@@ -32,6 +32,7 @@ import var Foundation.NSLocalizedDescriptionKey
3232
/// - Removing `.` path components
3333
/// - Removing any trailing path separator
3434
/// - Removing any redundant path separators
35+
/// - Converting the disk designator to uppercase (Windows) i.e. c:\ to C:\
3536
///
3637
/// This string manipulation may change the meaning of a path if any of the
3738
/// path components are symbolic links on disk. However, the file system is
@@ -506,21 +507,30 @@ private struct WindowsPath: Path, Sendable {
506507
var components: [String] {
507508
let normalized: UnsafePointer<Int8> = string.fileSystemRepresentation
508509
defer { normalized.deallocate() }
509-
510-
return String(cString: normalized).components(separatedBy: "\\").filter { !$0.isEmpty }
510+
// Remove prefix from the components, allowing for comparison across normalized paths.
511+
var prefixStrippedPath = PathCchStripPrefix(String(cString: normalized))
512+
// The '\\.\'' prefix is not removed by PathCchStripPrefix do this manually.
513+
if prefixStrippedPath.starts(with: #"\\.\"#) {
514+
prefixStrippedPath = String(prefixStrippedPath.dropFirst(4))
515+
}
516+
return prefixStrippedPath.components(separatedBy: #"\"#).filter { !$0.isEmpty }
511517
}
512518

513519
var parentDirectory: Self {
514520
return self == .root ? self : Self(string: dirname)
515521
}
516522

517523
init(string: String) {
518-
if string.first?.isASCII ?? false, string.first?.isLetter ?? false, string.first?.isLowercase ?? false,
519-
string.count > 1, string[string.index(string.startIndex, offsetBy: 1)] == ":"
524+
let noPrefixPath = PathCchStripPrefix(string)
525+
let prefix = string.replacingOccurrences(of: noPrefixPath, with: "") // Just the prefix or empty
526+
527+
// Perform drive designator normalization i.e. 'c:\' to 'C:\' on string.
528+
if noPrefixPath.first?.isASCII ?? false, noPrefixPath.first?.isLetter ?? false, noPrefixPath.first?.isLowercase ?? false,
529+
noPrefixPath.count > 1, noPrefixPath[noPrefixPath.index(noPrefixPath.startIndex, offsetBy: 1)] == ":"
520530
{
521-
self.string = "\(string.first!.uppercased())\(string.dropFirst(1))"
531+
self.string = "\(prefix)\(noPrefixPath.first!.uppercased())\(noPrefixPath.dropFirst(1))"
522532
} else {
523-
self.string = string
533+
self.string = prefix + noPrefixPath
524534
}
525535
}
526536

@@ -536,7 +546,13 @@ private struct WindowsPath: Path, Sendable {
536546
if !Self.isAbsolutePath(realpath) {
537547
throw PathValidationError.invalidAbsolutePath(path)
538548
}
539-
self.init(string: realpath)
549+
do {
550+
let canonicalizedPath = try canonicalPathRepresentation(realpath)
551+
let normalizedPath = PathCchRemoveBackslash(canonicalizedPath) // AbsolutePath states paths have no trailing separator.
552+
self.init(string: normalizedPath)
553+
} catch {
554+
throw PathValidationError.invalidAbsolutePath("\(path): \(error)")
555+
}
540556
}
541557

542558
init(validatingRelativePath path: String) throws {
@@ -554,12 +570,20 @@ private struct WindowsPath: Path, Sendable {
554570

555571
func suffix(withDot: Bool) -> String? {
556572
return self.string.withCString(encodedAs: UTF16.self) {
557-
if let pointer = PathFindExtensionW($0) {
558-
let substring = String(decodingCString: pointer, as: UTF16.self)
559-
guard substring.length > 0 else { return nil }
560-
return withDot ? substring : String(substring.dropFirst(1))
561-
}
562-
return nil
573+
if let dotPointer = PathFindExtensionW($0) {
574+
// If the dotPointer is the same as the full path, there are no components before
575+
// the suffix and therefore there is no suffix.
576+
if dotPointer == $0 {
577+
return nil
578+
}
579+
let substring = String(decodingCString: dotPointer, as: UTF16.self)
580+
// Substring must have a dot and one more character to be considered a suffix
581+
guard substring.length > 1 else {
582+
return nil
583+
}
584+
return withDot ? substring : String(substring.dropFirst(1))
585+
}
586+
return nil
563587
}
564588
}
565589

@@ -585,6 +609,111 @@ private struct WindowsPath: Path, Sendable {
585609
return Self(string: String(decodingCString: result!, as: UTF16.self))
586610
}
587611
}
612+
613+
fileprivate func HRESULT_CODE(_ hr: HRESULT) -> DWORD {
614+
DWORD(hr) & 0xFFFF
615+
}
616+
617+
@inline(__always)
618+
fileprivate func HRESULT_FACILITY(_ hr: HRESULT) -> DWORD {
619+
DWORD(hr << 16) & 0x1FFF
620+
}
621+
622+
@inline(__always)
623+
fileprivate func SUCCEEDED(_ hr: HRESULT) -> Bool {
624+
hr >= 0
625+
}
626+
627+
// This is a non-standard extension to the Windows SDK that allows us to convert
628+
// an HRESULT to a Win32 error code.
629+
@inline(__always)
630+
fileprivate func WIN32_FROM_HRESULT(_ hr: HRESULT) -> DWORD {
631+
if SUCCEEDED(hr) { return DWORD(ERROR_SUCCESS) }
632+
if HRESULT_FACILITY(hr) == FACILITY_WIN32 {
633+
return HRESULT_CODE(hr)
634+
}
635+
return DWORD(hr)
636+
}
637+
638+
/// Create a canonicalized path representation for Windows.
639+
/// Returns a potentially `\\?\`-prefixed version of the path,
640+
/// to ensure long paths greater than MAX_PATH (260) characters are handled correctly.
641+
///
642+
/// - seealso: https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation
643+
fileprivate func canonicalPathRepresentation(_ path: String) throws -> String {
644+
return try path.withCString(encodedAs: UTF16.self) { pwszPlatformPath in
645+
// 1. Normalize the path first.
646+
// Contrary to the documentation, this works on long paths independently
647+
// of the registry or process setting to enable long paths (but it will also
648+
// not add the \\?\ prefix required by other functions under these conditions).
649+
let dwLength: DWORD = GetFullPathNameW(pwszPlatformPath, 0, nil, nil)
650+
651+
return try withUnsafeTemporaryAllocation(of: WCHAR.self, capacity: Int(dwLength)) { pwszFullPath in
652+
guard (1 ..< dwLength).contains(GetFullPathNameW(pwszPlatformPath, DWORD(pwszFullPath.count), pwszFullPath.baseAddress, nil)) else {
653+
throw Win32Error(GetLastError())
654+
}
655+
// 1.5 Leave \\.\ prefixed paths alone since device paths are already an exact representation and PathCchCanonicalizeEx will mangle these.
656+
if pwszFullPath.count >= 4 {
657+
if let base = pwszFullPath.baseAddress,
658+
base[0] == UInt8(ascii: "\\"),
659+
base[1] == UInt8(ascii: "\\"),
660+
base[2] == UInt8(ascii: "."),
661+
base[3] == UInt8(ascii: "\\")
662+
{
663+
return String(decodingCString: base, as: UTF16.self)
664+
}
665+
}
666+
// 2. Canonicalize the path.
667+
// This will add the \\?\ prefix if needed based on the path's length.
668+
var pwszCanonicalPath: LPWSTR?
669+
let flags: ULONG = numericCast(PATHCCH_ALLOW_LONG_PATHS.rawValue)
670+
let result = PathAllocCanonicalize(pwszFullPath.baseAddress, flags, &pwszCanonicalPath)
671+
if let pwszCanonicalPath {
672+
defer { LocalFree(pwszCanonicalPath) }
673+
if result == S_OK {
674+
// 3. Perform the operation on the normalized path.
675+
return String(decodingCString: pwszCanonicalPath, as: UTF16.self)
676+
}
677+
}
678+
throw Win32Error(WIN32_FROM_HRESULT(result))
679+
}
680+
}
681+
}
682+
683+
/// Removes the "\\?\" prefix, if present, from a file path. When this function returns successfully,
684+
/// the same path string will have the prefix removed,if the prefix was present.
685+
/// If no prefix was present,the string will be unchanged.
686+
fileprivate func PathCchStripPrefix(_ path: String) -> String {
687+
return path.withCString(encodedAs: UTF16.self) { cStringPtr in
688+
withUnsafeTemporaryAllocation(of: WCHAR.self, capacity: path.utf16.count + 1) { buffer in
689+
buffer.initialize(from: UnsafeBufferPointer(start: cStringPtr, count: path.utf16.count + 1))
690+
let result = PathCchStripPrefix(buffer.baseAddress!, buffer.count)
691+
if result == S_OK {
692+
return String(decodingCString: buffer.baseAddress!, as: UTF16.self)
693+
}
694+
return path
695+
}
696+
}
697+
}
698+
699+
/// Remove a trailing backslash from a path if the following conditions
700+
/// are true:
701+
/// * Path is not a root path
702+
/// * Pash has a trailing backslash
703+
/// If conditions are not met then the string is returned unchanged.
704+
fileprivate func PathCchRemoveBackslash(_ path: String) -> String {
705+
return path.withCString(encodedAs: UTF16.self) { cStringPtr in
706+
return withUnsafeTemporaryAllocation(of: WCHAR.self, capacity: path.utf16.count + 1) { buffer in
707+
buffer.initialize(from: UnsafeBufferPointer(start: cStringPtr, count: path.utf16.count + 1))
708+
let result = PathCchRemoveBackslash(buffer.baseAddress!, path.utf16.count + 1)
709+
if result == S_OK {
710+
return String(decodingCString: buffer.baseAddress!, as: UTF16.self)
711+
}
712+
return path
713+
}
714+
return path
715+
}
716+
}
588717
#else
589718
private struct UNIXPath: Path, Sendable {
590719
let string: String
@@ -966,7 +1095,8 @@ extension AbsolutePath {
9661095
}
9671096
}
9681097

969-
assert(AbsolutePath(base, result) == self)
1098+
assert(AbsolutePath(base, result) == self, "base:\(base) result:\(result) self: \(self)")
1099+
9701100
return result
9711101
}
9721102

Sources/TSCBasic/Win32Error.swift

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See http://swift.org/LICENSE.txt for license information
8+
See http://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
#if os(Windows)
12+
public import WinSDK
13+
import Foundation
14+
15+
public struct Win32Error: Error, CustomStringConvertible {
16+
public let error: DWORD
17+
18+
public init(_ error: DWORD) {
19+
self.error = error
20+
}
21+
22+
public var description: String {
23+
let flags: DWORD = DWORD(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS)
24+
var buffer: UnsafeMutablePointer<WCHAR>?
25+
let length: DWORD = withUnsafeMutablePointer(to: &buffer) {
26+
$0.withMemoryRebound(to: WCHAR.self, capacity: 2) {
27+
FormatMessageW(flags, nil, error, 0, $0, 0, nil)
28+
}
29+
}
30+
guard let buffer, length > 0 else {
31+
return "Win32 Error Code \(error)"
32+
}
33+
defer { LocalFree(buffer) }
34+
return String(decodingCString: buffer, as: UTF16.self).trimmingCharacters(in: .whitespacesAndNewlines)
35+
}
36+
}
37+
#endif

0 commit comments

Comments
 (0)