-
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
Resolves #505 - Fix handling for Windows long paths #506
Conversation
@swift-ci test |
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.
Most of this is style or white space changes which really makes this harder to review. Can you split those out into separate PRs that can be merged independently?
Sources/TSCBasic/Path.swift
Outdated
static func stripPrefix(_ path: String) -> String { | ||
return path.withCString(encodedAs: UTF16.self) { cStringPtr in | ||
let mutableCStringPtr = UnsafeMutablePointer(mutating: cStringPtr) | ||
let result = PathCchStripPrefix(mutableCStringPtr, path.count + 1) |
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.
Should this not be path.utf16.count
?
Sources/TSCBasic/Path.swift
Outdated
static func removeTrailingBackslash(_ path: String) -> String { | ||
return path.withCString(encodedAs: UTF16.self) { cStringPtr in | ||
let mutableCStringPtr = UnsafeMutablePointer(mutating: cStringPtr) | ||
let result = PathCchRemoveBackslash(mutableCStringPtr, path.count + 1) |
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.
Similar
Yes for sure |
0fef8bc
to
5f5b9e5
Compare
PR#369 - swiftlang#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
5f5b9e5
to
e267bc3
Compare
Sources/TSCBasic/Path.swift
Outdated
@@ -457,6 +458,79 @@ private struct WindowsPath: Path, Sendable { | |||
return !path.withCString(encodedAs: UTF16.self, PathIsRelativeW) | |||
} | |||
|
|||
/// When this function returns successfully, the same path string will have had the prefix removed, | |||
/// if the prefix was present. If no prefix was present, the string will be unchanged. | |||
static func stripPrefix(_ path: String) -> String { |
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.
suggestion: can we add automated tests that verify this function in isolation?
Sources/TSCBasic/Path.swift
Outdated
/// * Path is not a root path | ||
/// * Pash has a trailing backslash | ||
/// If conditions are not met then the string is returned unchanged. | ||
static func removeTrailingBackslash(_ path: String) -> String { |
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.
suggestion: can we add automated tests that verify this function in isolation?
Sources/TSCBasic/Path.swift
Outdated
/// 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 | ||
static func canonicalPathRepresentation(_ path: String) throws -> String { |
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.
suggestion: can we add automated tests that verify this function in isolation?
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.
note: this is copied mostly verbatim from swift-foundation; I think a comment that that's the case and it generally shouldn't be changed independently, would be better than tests. Really wish we had a package that could share this implementation rather than having to duplicate it everywhere.
@swift-ci test |
Sources/TSCBasic/Path.swift
Outdated
/// if the prefix was present. If no prefix was present, the string will be unchanged. | ||
static func stripPrefix(_ path: String) -> String { | ||
return path.withCString(encodedAs: UTF16.self) { cStringPtr in | ||
let mutableCStringPtr = UnsafeMutablePointer(mutating: cStringPtr) |
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.
This looks unsafe, you're taking a mutable view onto an immutable pointer; I think you need to use withUnsafeTemporaryAllocation and copy to a new buffer first.
Sources/TSCBasic/Path.swift
Outdated
@@ -457,6 +458,79 @@ private struct WindowsPath: Path, Sendable { | |||
return !path.withCString(encodedAs: UTF16.self, PathIsRelativeW) | |||
} | |||
|
|||
/// When this function returns successfully, the same path string will have had the prefix removed, | |||
/// if the prefix was present. If no prefix was present, the string will be unchanged. | |||
static func stripPrefix(_ path: String) -> String { |
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.
I'd name this Win32PathCchStripPrefix to make it clear that it provides the semantics of that Win32 function; "stripPrefix" is quite general otherwise -- strip what 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.
I don't think that Win32
is the right prefix, this is not Win32 related. We could use Zw
as the prefix as per the NT internals.
Sources/TSCBasic/Path.swift
Outdated
/// If conditions are not met then the string is returned unchanged. | ||
static func removeTrailingBackslash(_ path: String) -> String { | ||
return path.withCString(encodedAs: UTF16.self) { cStringPtr in | ||
let mutableCStringPtr = UnsafeMutablePointer(mutating: cStringPtr) |
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.
Same comment about memory safety.
Sources/TSCBasic/Path.swift
Outdated
// 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) | numericCast(PATHCCH_CANONICALIZE_SLASHES.rawValue) |
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.
Why did you add PATHCCH_CANONICALIZE_SLASHES? I hadn't found it to be necessary, because GetFullPathNameW will have already done that. Our sole reason for calling PathAllocCanonicalize here is to prepend the \?\ prefix for long paths, which are otherwise effectively already normalized by the aforementioned call.
Note also that this value is only available in Windows 11 version 10.0.22000.194 and might cause problems on older OS versions, or at least will be ignored. CI is definitely on Windows 10 so it won't reflect any behaviors resulting from this.
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, it is not required, and I have removed that additional flag.
let noPrefixPath = Self.stripPrefix(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 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?
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.
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 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.
Sources/TSCBasic/Path.swift
Outdated
@@ -536,7 +614,13 @@ private struct WindowsPath: Path, Sendable { | |||
if !Self.isAbsolutePath(realpath) { | |||
throw PathValidationError.invalidAbsolutePath(path) | |||
} | |||
self.init(string: realpath) | |||
do { | |||
let canonicalizedPath = try Self.canonicalPathRepresentation(realpath) |
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.
Under what circumstances are you seeing Self.canonicalPathRepresentation return a path with a trailing slash? Because it goes through GetFullPathW, I don't see how this could be the case.
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.
If I remove the call to remove the trailing slashes
let canonicalizedPath = try Self.canonicalPathRepresentation(realpath)
let normalizedPath = canonicalizedPath
//let normalizedPath = Self.removeTrailingBackslash(canonicalizedPath) // AbsolutePath states paths have no trailing separator.
Then I get some failures on even simple paths.
XCTAssertEqual(AbsolutePath(#"C:\ab\cd\ef\"#).pathString, #"C:\ab\cd\ef"#)
C:\Users\kcieplak\swift-tools-support-core\Tests\TSCBasicTests\PathTests.swift:143: error: PathTests.testTrailingPathSeparators : XCTAssertEqual failed: ("C:\ab\cd\ef\") is not equal to ("C:\ab\cd\ef") -
XCTAssertEqual(AbsolutePath(#"C:\ab\cd\ef\\"#).pathString, #"C:\ab\cd\ef"#)
C:\Users\kcieplak\swift-tools-support-core\Tests\TSCBasicTests\PathTests.swift:144: error: PathTests.testTrailingPathSeparators : XCTAssertEqual failed: ("C:\ab\cd\ef\") is not equal to ("C:\ab\cd\ef") -
let longAbsolutePathOverPathMax = generatePath(280)
XCTAssertEqual(AbsolutePath(longAbsolutePathOverPathMax + #"\"#).pathString, #"\\?\"# + longAbsolutePathOverPathMax)
C:\Users\kcieplak\swift-tools-support-core\Tests\TSCBasicTests\PathTests.swift:148: error: PathTests.testTrailingPathSeparators : XCTAssertEqual failed: ("\\?\C:\0\1\2\3\4\5\6\7\8\9\10\11\12\13\14\15\16\17\18\19\20\21\22\23\24\25\26\27\28\29\30\31\32\33\34\35\36\37\38\39\40\41\42\43\44\45\46\47\48\49\50\51\52\53\54\55\56\57\58\59\60\61\62\63\64\65\66\67\68\69\70\71\72\73\74\75\76\77\78\79\80\81\82\83\84\85\86\87\88\89\90\91\92\93\94\95\") is not equal to ("\\?\C:\0\1\2\3\4\5\6\7\8\9\10\11\12\13\14\15\16\17\18\19\20\21\22\23\24\25\26\27\28\29\30\31\32\33\34\35\36\37\38\39\40\41\42\43\44\45\46\47\48\49\50\51\52\53\54\55\56\57\58\59\60\61\62\63\64\65\66\67\68\69\70\71\72\73\74\75\76\77\78\79\80\81\82\83\84\85\86\87\88\89\90\91\92\93\94\95") -
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.
Similar question as previously, does Self.
add anything? Are we expecting subclasses to replace the implementation?
var dirNameCount = 0 | ||
while currentPathLength < length { | ||
let dirName = String(dirNameCount) | ||
path.append("\(path.count != 0 ? separator : "")\(dirName)") |
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.
Note that path components on Windows generally have a maximum of 255 characters; you might want to account for that here.
@@ -1,31 +1,75 @@ | |||
/* |
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.
Would be good to add some tests where the input has the \\?\
prefix already, as well as some which have the \\.\
prefix, especially with different forms of backslashes following that, e.g.:
\\?\C:\test
\\?\C:/test
\\.\C:\test
\\.\C:/test
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.
In my latest commit I have added a bunch of tests using the unParsed '\\?\' and device '\\.\' prefixed paths.
There are likely more edge case tests that should be added for them, but it's a start.
The tests right now are very declarative, nice to read but lots of repetition, it might be work trying to make them more programatic.
@swift-ci test |
Sources/TSCBasic/Path.swift
Outdated
@@ -457,6 +458,83 @@ private struct WindowsPath: Path, Sendable { | |||
return !path.withCString(encodedAs: UTF16.self, PathIsRelativeW) | |||
} | |||
|
|||
/// When this function returns successfully, the same path string will have had the prefix removed, | |||
/// if the prefix was present. If no prefix was present, the string will be unchanged. | |||
static func Win32PathCchStripPrefix(_ path: String) -> String { |
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.
I don't think that this should be Win32PathCchStripPrefix
. This is not a Win32 prefix but rather a kernel 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.
Changed to 'stripRawPathPrefix'
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.
Sorry, I suggested that name :) Should we just call it PathCchStripPrefix, same as the underlying Win32 function, and let overload resolution handle picking the right one?
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.
Yeah, that sounds good to me. I think that I've done that in the past as well, just overloading the Win32 APIs with a more Swift friendly interface.
@swift-ci test |
1e582d4
to
b25da2d
Compare
@swift-ci test |
// Remove prefix from the components, allowing for comparison across normalized paths. | ||
var prefixStrippedPath = Self.stripRawPathPrefix(String(cString: normalized)) | ||
// The '\\.\'' prefix is not removed by stripRawPathPrefix do this manually. | ||
if prefixStrippedPath.starts(with: #"\\.\"#) { |
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 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.
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.
Sources/TSCBasic/Path.swift
Outdated
/// 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. | ||
static func stripRawPathPrefix(_ path: String) -> String { |
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.
I think that PathCchStripPrefix
is the agreed upon name (overloading the WinSDK interface). Although, might be nice to just make this a freestanding function. I'm not sure that there is any value in embedding this into the type.
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.
Will do, moving back to match the Windows naming.
Sources/TSCBasic/Path.swift
Outdated
/// * Path is not a root path | ||
/// * Pash has a trailing backslash | ||
/// If conditions are not met then the string is returned unchanged. | ||
static func removeTrailingBackslash(_ path: String) -> String { |
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.
Thoughts on just naming this PathCchRemoveBackslash
? Similarly, I think that this can be a free function.
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.
Renamed to PatchCchRemoveBackslash, and standalone.
Sources/TSCBasic/Path.swift
Outdated
|
||
return String(cString: normalized).components(separatedBy: "\\").filter { !$0.isEmpty } | ||
// Remove prefix from the components, allowing for comparison across normalized paths. | ||
var prefixStrippedPath = Self.stripRawPathPrefix(String(cString: normalized)) |
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.
Why the Self.
? Do you expect subtypes which override the behaviour?
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.
No just an omission.
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.
Removed in favour of a basic standalone function.
// Remove prefix from the components, allowing for comparison across normalized paths. | ||
var prefixStrippedPath = Self.stripRawPathPrefix(String(cString: normalized)) | ||
// The '\\.\'' prefix is not removed by stripRawPathPrefix do this manually. | ||
if prefixStrippedPath.starts(with: #"\\.\"#) { |
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 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.
b25da2d
to
83d7041
Compare
@swift-ci test |
@swift-ci test windows |
83d7041
to
8ea5813
Compare
@swift-ci test |
@swift-ci test windows |
Sources/TSCBasic/Path.swift
Outdated
throw Win32Error(GetLastError()) | ||
} | ||
// 1.5 Leave \\.\ prefixed paths alone since device paths are already an exact representation and PathCchCanonicalizeEx will mangle these. | ||
if let base = pwszFullPath.baseAddress, |
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.
Hmm, I think that we missed this on the changes in Foundation as well, but should we not ensure that pwszFullPath.count
is >= 4
?
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.
Yes, good catch. I will add a conditional around that.
Sources/TSCBasic/Path.swift
Outdated
{ | ||
self.string = "\(string.first!.uppercased())\(string.dropFirst(1))" | ||
self.string = prefix + "\(noPrefixPath.first!.uppercased())\(noPrefixPath.dropFirst(1))" |
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.
Why not just merge this into the string interpolation?
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.
Yup might as well, already paying the interpolation cost.
Sources/TSCBasic/Path.swift
Outdated
@@ -536,7 +614,13 @@ private struct WindowsPath: Path, Sendable { | |||
if !Self.isAbsolutePath(realpath) { | |||
throw PathValidationError.invalidAbsolutePath(path) | |||
} | |||
self.init(string: realpath) | |||
do { | |||
let canonicalizedPath = try Self.canonicalPathRepresentation(realpath) |
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.
Similar question as previously, does Self.
add anything? Are we expecting subclasses to replace the implementation?
- 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
8ea5813
to
7de5288
Compare
@swift-ci test |
@swift-ci test windows |
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.
Thanks for sticking with this, this looks good to me (the remaining comments are minor/trivial).
} else { | ||
self.string = string | ||
self.string = prefix + noPrefixPath | ||
} |
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.
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).
if pwszFullPath.count >= 4 { | ||
if let base = pwszFullPath.baseAddress, |
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.
Style nit: I would've compressed this to:
if pwszFullPath.count >= 4, let base = pwszFullPath.baseAddress, ...
PR#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.