diff --git a/Sources/FoundationEssentials/URL/URL_Swift.swift b/Sources/FoundationEssentials/URL/URL_Swift.swift index 7da7365e2..0507990ce 100644 --- a/Sources/FoundationEssentials/URL/URL_Swift.swift +++ b/Sources/FoundationEssentials/URL/URL_Swift.swift @@ -41,6 +41,8 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable { internal let _parseInfo: URLParseInfo internal let _baseURL: URL? internal let _encoding: String.Encoding + // URL was created from a file path initializer and is absolute + private let _isCanonicalFileURL: Bool #if FOUNDATION_FRAMEWORK // Used frequently for NS/CFURL behaviors @@ -90,6 +92,7 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable { _parseInfo = parseInfo _baseURL = (forceBaseURL || parseInfo.scheme == nil) ? base?.absoluteURL : nil _encoding = encoding + _isCanonicalFileURL = false } convenience init?(string: String) { @@ -132,10 +135,13 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable { } convenience init(filePath path: String, directoryHint: URL.DirectoryHint = .inferFromPath, relativeTo base: URL? = nil) { - self.init(filePath: path, pathStyle: URL.defaultPathStyle, directoryHint: directoryHint, relativeTo: base) + // .init(fileURLWithPath:) inits call through here, convert path to FSR now + self.init(filePath: path.fileSystemRepresentation, pathStyle: URL.defaultPathStyle, directoryHint: directoryHint, relativeTo: base) } internal init(filePath path: String, pathStyle: URL.PathStyle, directoryHint: URL.DirectoryHint = .inferFromPath, relativeTo base: URL? = nil) { + // Note: don't convert to file system representation in this init since + // .init(fileURLWithFileSystemRepresentation:) calls into it, too. var baseURL = base guard !path.isEmpty else { #if !NO_FILESYSTEM @@ -144,6 +150,7 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable { _parseInfo = Parser.parse(filePath: "./", isAbsolute: false) _baseURL = baseURL?.absoluteURL _encoding = .utf8 + _isCanonicalFileURL = false return } @@ -176,6 +183,7 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable { _parseInfo = parseInfo _baseURL = nil // Drop the base URL since we have an HTTP scheme _encoding = .utf8 + _isCanonicalFileURL = false return } } @@ -220,10 +228,12 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable { let encodedPath = Parser.percentEncode(filePath, component: .path) ?? "/" _parseInfo = Parser.parse(filePath: encodedPath, isAbsolute: true) _baseURL = nil // Drop the baseURL if the URL is absolute + _isCanonicalFileURL = true } else { let encodedPath = Parser.percentEncode(filePath, component: .path) ?? "" _parseInfo = Parser.parse(filePath: encodedPath, isAbsolute: false) _baseURL = baseURL?.absoluteURL + _isCanonicalFileURL = false } _encoding = .utf8 } @@ -232,6 +242,7 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable { _parseInfo = url._parseInfo _baseURL = url._baseURL?.absoluteURL _encoding = url._encoding + _isCanonicalFileURL = url._isCanonicalFileURL } convenience init?(dataRepresentation: Data, relativeTo base: URL?, isAbsolute: Bool) { @@ -256,7 +267,8 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable { convenience init(fileURLWithFileSystemRepresentation path: UnsafePointer, isDirectory: Bool, relativeTo base: URL?) { let pathString = String(cString: path) let directoryHint: URL.DirectoryHint = isDirectory ? .isDirectory : .notDirectory - self.init(filePath: pathString, directoryHint: directoryHint, relativeTo: base) + // Call the internal init so we don't automatically convert path to its decomposed form + self.init(filePath: pathString, pathStyle: URL.defaultPathStyle, directoryHint: directoryHint, relativeTo: base) } internal var encodedComponents: URLParseInfo.EncodedComponentSet { @@ -389,6 +401,7 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable { private static let fileSchemeUTF8 = Array("file".utf8) var isFileURL: Bool { + if _isCanonicalFileURL { return true } guard let scheme else { return false } return scheme.lowercased().utf8.elementsEqual(Self.fileSchemeUTF8) } @@ -624,6 +637,11 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable { } func withUnsafeFileSystemRepresentation(_ block: (UnsafePointer?) throws -> ResultType) rethrows -> ResultType { + #if !os(Windows) + if _isCanonicalFileURL { + return try fileSystemPath().withCString { try block($0) } + } + #endif return try fileSystemPath().withFileSystemRepresentation { try block($0) } } @@ -693,6 +711,13 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable { var pathToAppend = String(path) #endif + #if FOUNDATION_FRAMEWORK + if isFileURL { + // Use the file system (decomposed) representation + pathToAppend = pathToAppend.fileSystemRepresentation + } + #endif + if !encodingSlashes && !compatibility { pathToAppend = Parser.percentEncode(pathComponent: pathToAppend) } else { @@ -880,6 +905,13 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable { guard !pathExtension.isEmpty, !_parseInfo.path.isEmpty else { return nil } + #if FOUNDATION_FRAMEWORK + var pathExtension = pathExtension + if isFileURL { + // Use the file system (decomposed) representation + pathExtension = pathExtension.fileSystemRepresentation + } + #endif var components = URLComponents(parseInfo: _parseInfo) // pathExtension might need to be percent-encoded let encodedExtension = if compatibility { @@ -1101,6 +1133,21 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable { } +private extension String { + var fileSystemRepresentation: String { + #if FOUNDATION_FRAMEWORK + return withFileSystemRepresentation { fsRep in + guard let fsRep else { + return self + } + return String(cString: fsRep) + } + #else + return self + #endif + } +} + #if FOUNDATION_FRAMEWORK internal import CoreFoundation_Private.CFURL diff --git a/Tests/FoundationEssentialsTests/URLTests.swift b/Tests/FoundationEssentialsTests/URLTests.swift index 4b1f75d5f..344454a67 100644 --- a/Tests/FoundationEssentialsTests/URLTests.swift +++ b/Tests/FoundationEssentialsTests/URLTests.swift @@ -423,6 +423,163 @@ private struct URLTests { try FileManager.default.removeItem(at: URL(filePath: "\(tempDirectory.path)/tmp-dir")) } + #if FOUNDATION_FRAMEWORK + @Test func fileSystemRepresentations() throws { + let base = "/base/" + let pathNFC = "/caf\u{E9}" + let relativeNFC = "caf\u{E9}" + let pathNFD = "/cafe\u{301}" + let relativeNFD = "cafe\u{301}" + + let resolvedPathNFC = "/base/caf\u{E9}" + let resolvedPathNFD = "/base/cafe\u{301}" + let baseExtensionNFD = "/base.cafe\u{301}" + let doubleCafeNFD = "/cafe\u{301}/cafe\u{301}" + + // URL(filePath:) should always convert the input to decomposed (NFD) representation + let baseURL = URL(filePath: base) + let urlNFC = URL(filePath: pathNFC) + let urlRelativeNFC = URL(filePath: relativeNFC, relativeTo: baseURL) + let urlNFD = URL(filePath: pathNFD) + let urlRelativeNFD = URL(filePath: relativeNFD, relativeTo: baseURL) + + func equalBytes(_ p1: UnsafePointer, _ p2: UnsafePointer) -> Bool { + return strcmp(p1, p2) == 0 + } + + // Compare bytes to ensure we have the right representation + #expect(equalBytes(urlNFC.path, pathNFD)) + #expect(equalBytes(urlNFD.path, pathNFD)) + #expect(urlNFC == urlNFD) + + #expect(equalBytes(urlRelativeNFC.path, resolvedPathNFD)) + #expect(equalBytes(urlRelativeNFD.path, resolvedPathNFD)) + #expect(urlRelativeNFC == urlRelativeNFD) + + // withUnsafeFileSystemRepresentation should return a pointer to decomposed bytes + try urlNFC.withUnsafeFileSystemRepresentation { fsRep in + let fsRep = try #require(fsRep) + #expect(equalBytes(fsRep, pathNFD)) + } + + try urlNFD.withUnsafeFileSystemRepresentation { fsRep in + let fsRep = try #require(fsRep) + #expect(equalBytes(fsRep, pathNFD)) + } + + try urlRelativeNFC.withUnsafeFileSystemRepresentation { fsRep in + let fsRep = try #require(fsRep) + #expect(equalBytes(fsRep, resolvedPathNFD)) + } + + try urlRelativeNFD.withUnsafeFileSystemRepresentation { fsRep in + let fsRep = try #require(fsRep) + #expect(equalBytes(fsRep, resolvedPathNFD)) + } + + // ...unless we specifically .init(fileURLWithFileSystemRepresentation:) with absolute NFC + let urlNFCFSR = URL(fileURLWithFileSystemRepresentation: pathNFC, isDirectory: false, relativeTo: nil) + let urlNFDFSR = URL(fileURLWithFileSystemRepresentation: pathNFD, isDirectory: false, relativeTo: nil) + + #expect(equalBytes(urlNFCFSR.path, pathNFC)) + #expect(equalBytes(urlNFDFSR.path, pathNFD)) + #expect(urlNFCFSR != urlNFDFSR) + + try urlNFCFSR.withUnsafeFileSystemRepresentation { fsRep in + let fsRep = try #require(fsRep) + #expect(equalBytes(fsRep, pathNFC)) + } + + try urlNFDFSR.withUnsafeFileSystemRepresentation { fsRep in + let fsRep = try #require(fsRep) + #expect(equalBytes(fsRep, pathNFD)) + } + + // If we .init(fileURLWithFileSystemRepresentation:) with a relative path, + // we store the given representation but must convert when returning it + let urlRelativeNFCFSR = URL(fileURLWithFileSystemRepresentation: relativeNFC, isDirectory: false, relativeTo: baseURL) + let urlRelativeNFDFSR = URL(fileURLWithFileSystemRepresentation: relativeNFD, isDirectory: false, relativeTo: baseURL) + + #expect(equalBytes(urlRelativeNFCFSR.path, resolvedPathNFC)) + #expect(equalBytes(urlRelativeNFDFSR.path, resolvedPathNFD)) + #expect(urlRelativeNFCFSR != urlRelativeNFDFSR) + + try urlRelativeNFCFSR.withUnsafeFileSystemRepresentation { fsRep in + let fsRep = try #require(fsRep) + #expect(equalBytes(fsRep, resolvedPathNFD)) + } + + try urlRelativeNFDFSR.withUnsafeFileSystemRepresentation { fsRep in + let fsRep = try #require(fsRep) + #expect(equalBytes(fsRep, resolvedPathNFD)) + } + + // Appending a path component should convert to decomposed for file URLs + let baseWithNFCComponent = baseURL.appending(path: relativeNFC) + #expect(equalBytes(baseWithNFCComponent.path, resolvedPathNFD)) + + let baseWithNFDComponent = baseURL.appending(path: relativeNFD) + #expect(equalBytes(baseWithNFDComponent.path, resolvedPathNFD)) + #expect(baseWithNFCComponent == baseWithNFDComponent) + + let urlNFCWithNFCComponent = urlNFC.appending(path: relativeNFC) + let urlNFCWithNFDComponent = urlNFC.appending(path: relativeNFD) + let urlNFDWithNFCComponent = urlNFD.appending(path: relativeNFC) + let urlNFDWithNFDComponent = urlNFD.appending(path: relativeNFD) + #expect(equalBytes(urlNFCWithNFCComponent.path, doubleCafeNFD)) + #expect(equalBytes(urlNFCWithNFDComponent.path, doubleCafeNFD)) + #expect(equalBytes(urlNFDWithNFCComponent.path, doubleCafeNFD)) + #expect(equalBytes(urlNFDWithNFDComponent.path, doubleCafeNFD)) + #expect(urlNFCWithNFCComponent == urlNFCWithNFDComponent) + #expect(urlNFCWithNFCComponent == urlNFDWithNFCComponent) + #expect(urlNFCWithNFCComponent == urlNFDWithNFDComponent) + + // Appending an extension should convert to decomposed for file URLs + let baseWithNFCExtension = baseURL.appendingPathExtension(relativeNFC) + #expect(equalBytes(baseWithNFCExtension.path, baseExtensionNFD)) + + let baseWithNFDExtension = baseURL.appendingPathExtension(relativeNFD) + #expect(equalBytes(baseWithNFDExtension.path, baseExtensionNFD)) + #expect(baseWithNFCExtension == baseWithNFDExtension) + + // None of these conversions apply for initializing or appending to non-file URLs + let httpBase = try #require(URL(string: "https://example.com/")) + let httpRelativeNFC = try #require(URL(string: relativeNFC, relativeTo: httpBase)) + let httpRelativeNFD = try #require(URL(string: relativeNFD, relativeTo: httpBase)) + let httpWithNFCComponent = httpBase.appending(path: relativeNFC) + let httpWithNFDComponent = httpBase.appending(path: relativeNFD) + + #expect(equalBytes(httpRelativeNFC.path, pathNFC)) + #expect(equalBytes(httpRelativeNFD.path, pathNFD)) + #expect(httpRelativeNFC != httpRelativeNFD) + + #expect(equalBytes(httpWithNFCComponent.path, pathNFC)) + #expect(equalBytes(httpWithNFDComponent.path, pathNFD)) + #expect(httpWithNFCComponent != httpWithNFDComponent) + + // Except when we explicitly get the file system representation + try httpRelativeNFC.withUnsafeFileSystemRepresentation { fsRep in + let fsRep = try #require(fsRep) + #expect(equalBytes(fsRep, pathNFD)) + } + + try httpRelativeNFD.withUnsafeFileSystemRepresentation { fsRep in + let fsRep = try #require(fsRep) + #expect(equalBytes(fsRep, pathNFD)) + } + + try httpWithNFCComponent.withUnsafeFileSystemRepresentation { fsRep in + let fsRep = try #require(fsRep) + #expect(equalBytes(fsRep, pathNFD)) + } + + try httpWithNFDComponent.withUnsafeFileSystemRepresentation { fsRep in + let fsRep = try #require(fsRep) + #expect(equalBytes(fsRep, pathNFD)) + } + } + #endif + #if os(Windows) @Test func windowsDriveLetterPath() throws { var url = URL(filePath: #"C:\test\path"#, directoryHint: .notDirectory)