Skip to content
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
51 changes: 49 additions & 2 deletions Sources/FoundationEssentials/URL/URL_Swift.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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
}

Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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) {
Expand All @@ -256,7 +267,8 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable {
convenience init(fileURLWithFileSystemRepresentation path: UnsafePointer<Int8>, 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 {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -624,6 +637,11 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable {
}

func withUnsafeFileSystemRepresentation<ResultType>(_ block: (UnsafePointer<Int8>?) throws -> ResultType) rethrows -> ResultType {
#if !os(Windows)
if _isCanonicalFileURL {
return try fileSystemPath().withCString { try block($0) }
}
#endif
return try fileSystemPath().withFileSystemRepresentation { try block($0) }
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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

Expand Down
157 changes: 157 additions & 0 deletions Tests/FoundationEssentialsTests/URLTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<CChar>, _ p2: UnsafePointer<CChar>) -> 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)
Expand Down