diff --git a/Sources/SWBUtil/CMakeLists.txt b/Sources/SWBUtil/CMakeLists.txt index 8e490da2..0be8ed58 100644 --- a/Sources/SWBUtil/CMakeLists.txt +++ b/Sources/SWBUtil/CMakeLists.txt @@ -68,6 +68,7 @@ add_library(SWBUtil OutputByteStream.swift Pair.swift Path.swift + PathWindows.swift PbxCp.swift PluginManager.swift PluginManagerCommon.swift diff --git a/Sources/SWBUtil/Library.swift b/Sources/SWBUtil/Library.swift index 1c6fe064..c96072d8 100644 --- a/Sources/SWBUtil/Library.swift +++ b/Sources/SWBUtil/Library.swift @@ -33,7 +33,7 @@ public enum Library: Sendable { @_alwaysEmitIntoClient public static func open(_ path: Path) throws -> LibraryHandle { #if os(Windows) - guard let handle = path.withPlatformString(LoadLibraryW) else { + guard let handle = try path.withPlatformString({ p in try p.withCanonicalPathRepresentation({ LoadLibraryW($0) }) }) else { throw LibraryOpenError(message: Win32Error(GetLastError()).description) } return LibraryHandle(rawValue: handle) diff --git a/Sources/SWBUtil/PathWindows.swift b/Sources/SWBUtil/PathWindows.swift new file mode 100644 index 00000000..f20d909d --- /dev/null +++ b/Sources/SWBUtil/PathWindows.swift @@ -0,0 +1,89 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 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 the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +#if os(Windows) +import WinSDK + +#if canImport(System) +public import System +#else +public import SystemPackage +#endif + +extension UnsafePointer where Pointee == CInterop.PlatformChar { + /// Invokes `body` with a resolved and potentially `\\?\`-prefixed version of the pointee, + /// 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 + public func withCanonicalPathRepresentation(_ body: (Self) throws -> Result) throws -> Result { + // 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(self, 0, nil, nil) + return try withUnsafeTemporaryAllocation(of: WCHAR.self, capacity: Int(dwLength)) { pwszFullPath in + guard (1.. 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) +} +#endif diff --git a/Tests/SWBUtilTests/PathWindowsTests.swift b/Tests/SWBUtilTests/PathWindowsTests.swift new file mode 100644 index 00000000..f81f7afb --- /dev/null +++ b/Tests/SWBUtilTests/PathWindowsTests.swift @@ -0,0 +1,92 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 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 the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Testing +import SWBTestSupport +import SWBUtil + +@Suite(.requireHostOS(.windows)) +fileprivate struct PathWindowsTests { + @Test func testCanonicalPathRepresentation_deviceFiles() throws { + #expect(try "NUL".canonicalPathRepresentation == "\\\\.\\NUL") + #expect(try Path("NUL").canonicalPathRepresentation == "\\\\.\\NUL") + + #expect(try "\\\\.\\NUL".canonicalPathRepresentation == "\\\\.\\NUL") + + // System.FilePath appends a trailing slash to fully qualified device file names + withKnownIssue { () throws -> () in + #expect(try Path("\\\\.\\NUL").canonicalPathRepresentation == "\\\\.\\NUL") + } + } + + @Test func testCanonicalPathRepresentation_driveLetters() throws { + #expect(try Path("C:/").canonicalPathRepresentation == "C:\\") + #expect(try Path("c:/").canonicalPathRepresentation == "c:\\") + + #expect(try Path("\\\\?\\C:/").canonicalPathRepresentation == "C:\\") + #expect(try Path("\\\\?\\c:/").canonicalPathRepresentation == "c:\\") + } + + @Test func testCanonicalPathRepresentation_absolute() throws { + #expect(try Path("C:" + String(repeating: "/foo/bar/baz", count: 21)).canonicalPathRepresentation == "C:" + String(repeating: "\\foo\\bar\\baz", count: 21)) + #expect(try Path("C:" + String(repeating: "/foo/bar/baz", count: 22)).canonicalPathRepresentation == "\\\\?\\C:" + String(repeating: "\\foo\\bar\\baz", count: 22)) + } + + @Test func testCanonicalPathRepresentation_relative() throws { + let root = Path.root.str.dropLast() + #expect(try Path(String(repeating: "/foo/bar/baz", count: 21)).canonicalPathRepresentation == root + String(repeating: "\\foo\\bar\\baz", count: 21)) + #expect(try Path(String(repeating: "/foo/bar/baz", count: 22)).canonicalPathRepresentation == "\\\\?\\" + root + String(repeating: "\\foo\\bar\\baz", count: 22)) + } + + @Test func testCanonicalPathRepresentation_driveRelative() throws { + let current = Path.currentDirectory + + // Ensure the output path will be < 260 characters so we can assert it's not prefixed with \\?\ + let chunks = (260 - current.str.count) / "foo/bar/baz/".count + #expect(current.str.count < 248 && chunks > 0, "The current directory is too long for this test.") + + #expect(try Path(current.str.prefix(2) + String(repeating: "foo/bar/baz/", count: chunks)).canonicalPathRepresentation == current.join(String(repeating: "\\foo\\bar\\baz", count: chunks)).str) + #expect(try Path(current.str.prefix(2) + String(repeating: "foo/bar/baz/", count: 22)).canonicalPathRepresentation == "\\\\?\\" + current.join(String(repeating: "\\foo\\bar\\baz", count: 22)).str) + } +} + +fileprivate extension String { + var canonicalPathRepresentation: String { + get throws { + #if os(Windows) + return try withCString(encodedAs: UTF16.self) { platformPath in + return try platformPath.withCanonicalPathRepresentation { canonicalPath in + return String(decodingCString: canonicalPath, as: UTF16.self) + } + } + #else + return self + #endif + } + } +} + +fileprivate extension Path { + var canonicalPathRepresentation: String { + get throws { + #if os(Windows) + return try withPlatformString { platformPath in + return try platformPath.withCanonicalPathRepresentation { canonicalPath in + return String(decodingCString: canonicalPath, as: UTF16.self) + } + } + #else + return str + #endif + } + } +}