Skip to content

Compatibility behaviors for Swift URL #1113

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

Merged
merged 1 commit into from
Jan 8, 2025
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
123 changes: 92 additions & 31 deletions Sources/FoundationEssentials/URL/URL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,10 @@ internal func foundation_swift_url_enabled() -> Bool {
internal func foundation_swift_url_enabled() -> Bool { return true }
#endif

#if canImport(os)
internal import os
#endif

/// A URL is a type that can potentially contain the location of a resource on a remote server, the path of a local file on disk, or even an arbitrary piece of encoded data.
///
/// You can construct URLs and access their parts. For URLs that represent local files, you can also manipulate properties of those files directly, such as changing the file's last modification date. Finally, you can pass URLs to other APIs to retrieve the contents of those URLs. For example, you can use the URLSession classes to access the contents of remote resources, as described in URL Session Programming Guide.
Expand All @@ -624,6 +628,12 @@ internal func foundation_swift_url_enabled() -> Bool { return true }
@available(macOS 10.10, iOS 8.0, watchOS 2.0, tvOS 9.0, *)
public struct URL: Equatable, Sendable, Hashable {

#if canImport(os)
internal static let logger: Logger = {
Logger(subsystem: "com.apple.foundation", category: "url")
}()
#endif

#if FOUNDATION_FRAMEWORK

private var _url: NSURL
Expand Down Expand Up @@ -763,6 +773,10 @@ public struct URL: Equatable, Sendable, Hashable {
internal var _parseInfo: URLParseInfo!
private var _baseParseInfo: URLParseInfo?

private static func parse(urlString: String, encodingInvalidCharacters: Bool = true) -> URLParseInfo? {
return Parser.parse(urlString: urlString, encodingInvalidCharacters: encodingInvalidCharacters, compatibility: .allowEmptyScheme)
}

internal init(parseInfo: URLParseInfo, relativeTo url: URL? = nil) {
_parseInfo = parseInfo
if parseInfo.scheme == nil {
Expand All @@ -773,6 +787,31 @@ public struct URL: Equatable, Sendable, Hashable {
#endif // FOUNDATION_FRAMEWORK
}

/// The public initializers don't allow the empty string, and we must maintain that behavior
/// for compatibility. However, there are cases internally where we need to create a URL with
/// an empty string, such as when `.deletingLastPathComponent()` of a single path
/// component. This previously worked since `URL` just wrapped an `NSURL`, which
/// allows the empty string.
internal init?(stringOrEmpty: String, relativeTo url: URL? = nil) {
#if FOUNDATION_FRAMEWORK
guard foundation_swift_url_enabled() else {
guard let inner = NSURL(string: stringOrEmpty, relativeTo: url) else { return nil }
_url = URL._converted(from: inner)
return
}
#endif // FOUNDATION_FRAMEWORK
guard let parseInfo = URL.parse(urlString: stringOrEmpty) else {
return nil
}
_parseInfo = parseInfo
if parseInfo.scheme == nil {
_baseParseInfo = url?.absoluteURL._parseInfo
}
#if FOUNDATION_FRAMEWORK
_url = URL._nsURL(from: _parseInfo, baseParseInfo: _baseParseInfo)
#endif // FOUNDATION_FRAMEWORK
}

/// Initialize with string.
///
/// Returns `nil` if a `URL` cannot be formed with the string (for example, if the string contains characters that are illegal in a URL, or is an empty string).
Expand All @@ -785,7 +824,7 @@ public struct URL: Equatable, Sendable, Hashable {
return
}
#endif // FOUNDATION_FRAMEWORK
guard let parseInfo = Parser.parse(urlString: string, encodingInvalidCharacters: true) else {
guard let parseInfo = URL.parse(urlString: string) else {
return nil
}
_parseInfo = parseInfo
Expand All @@ -798,14 +837,15 @@ public struct URL: Equatable, Sendable, Hashable {
///
/// Returns `nil` if a `URL` cannot be formed with the string (for example, if the string contains characters that are illegal in a URL, or is an empty string).
public init?(string: __shared String, relativeTo url: __shared URL?) {
guard !string.isEmpty else { return nil }
#if FOUNDATION_FRAMEWORK
guard foundation_swift_url_enabled() else {
guard !string.isEmpty, let inner = NSURL(string: string, relativeTo: url) else { return nil }
guard let inner = NSURL(string: string, relativeTo: url) else { return nil }
_url = URL._converted(from: inner)
return
}
#endif // FOUNDATION_FRAMEWORK
guard let parseInfo = Parser.parse(urlString: string, encodingInvalidCharacters: true) else {
guard let parseInfo = URL.parse(urlString: string) else {
return nil
}
_parseInfo = parseInfo
Expand All @@ -824,14 +864,15 @@ public struct URL: Equatable, Sendable, Hashable {
/// If the URL string is still invalid after encoding, `nil` is returned.
@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *)
public init?(string: __shared String, encodingInvalidCharacters: Bool) {
guard !string.isEmpty else { return nil }
#if FOUNDATION_FRAMEWORK
guard foundation_swift_url_enabled() else {
guard !string.isEmpty, let inner = NSURL(string: string, encodingInvalidCharacters: encodingInvalidCharacters) else { return nil }
guard let inner = NSURL(string: string, encodingInvalidCharacters: encodingInvalidCharacters) else { return nil }
_url = URL._converted(from: inner)
return
}
#endif // FOUNDATION_FRAMEWORK
guard let parseInfo = Parser.parse(urlString: string, encodingInvalidCharacters: encodingInvalidCharacters) else {
guard let parseInfo = URL.parse(urlString: string, encodingInvalidCharacters: encodingInvalidCharacters) else {
return nil
}
_parseInfo = parseInfo
Expand All @@ -858,7 +899,7 @@ public struct URL: Equatable, Sendable, Hashable {
}
#endif
let directoryHint: DirectoryHint = isDirectory ? .isDirectory : .notDirectory
self.init(filePath: path, directoryHint: directoryHint, relativeTo: base)
self.init(filePath: path.isEmpty ? "." : path, directoryHint: directoryHint, relativeTo: base)
}

/// Initializes a newly created file URL referencing the local file or directory at path, relative to a base URL.
Expand All @@ -877,7 +918,7 @@ public struct URL: Equatable, Sendable, Hashable {
return
}
#endif
self.init(filePath: path, directoryHint: .checkFileSystem, relativeTo: base)
self.init(filePath: path.isEmpty ? "." : path, directoryHint: .checkFileSystem, relativeTo: base)
}

/// Initializes a newly created file URL referencing the local file or directory at path.
Expand All @@ -898,7 +939,7 @@ public struct URL: Equatable, Sendable, Hashable {
}
#endif
let directoryHint: DirectoryHint = isDirectory ? .isDirectory : .notDirectory
self.init(filePath: path, directoryHint: directoryHint)
self.init(filePath: path.isEmpty ? "." : path, directoryHint: directoryHint)
}

/// Initializes a newly created file URL referencing the local file or directory at path.
Expand All @@ -917,7 +958,7 @@ public struct URL: Equatable, Sendable, Hashable {
return
}
#endif
self.init(filePath: path, directoryHint: .checkFileSystem)
self.init(filePath: path.isEmpty ? "." : path, directoryHint: .checkFileSystem)
}

// NSURL(fileURLWithPath:) can return nil incorrectly for some malformed paths
Expand All @@ -941,24 +982,24 @@ public struct URL: Equatable, Sendable, Hashable {
///
/// If the data representation is not a legal URL string as ASCII bytes, the URL object may not behave as expected. If the URL cannot be formed then this will return nil.
@available(macOS 10.11, iOS 9.0, watchOS 2.0, tvOS 9.0, *)
public init?(dataRepresentation: __shared Data, relativeTo url: __shared URL?, isAbsolute: Bool = false) {
public init?(dataRepresentation: __shared Data, relativeTo base: __shared URL?, isAbsolute: Bool = false) {
guard !dataRepresentation.isEmpty else { return nil }
#if FOUNDATION_FRAMEWORK
guard foundation_swift_url_enabled() else {
if isAbsolute {
_url = URL._converted(from: NSURL(absoluteURLWithDataRepresentation: dataRepresentation, relativeTo: url))
_url = URL._converted(from: NSURL(absoluteURLWithDataRepresentation: dataRepresentation, relativeTo: base))
} else {
_url = URL._converted(from: NSURL(dataRepresentation: dataRepresentation, relativeTo: url))
_url = URL._converted(from: NSURL(dataRepresentation: dataRepresentation, relativeTo: base))
}
return
}
#endif
var url: URL?
if let string = String(data: dataRepresentation, encoding: .utf8) {
url = URL(string: string, relativeTo: url)
url = URL(stringOrEmpty: string, relativeTo: base)
}
if url == nil, let string = String(data: dataRepresentation, encoding: .isoLatin1) {
url = URL(string: string, relativeTo: url)
url = URL(stringOrEmpty: string, relativeTo: base)
}
guard let url else {
return nil
Expand All @@ -983,7 +1024,7 @@ public struct URL: Equatable, Sendable, Hashable {
return
}
#endif
guard let parseInfo = Parser.parse(urlString: _url.relativeString, encodingInvalidCharacters: true) else {
guard let parseInfo = URL.parse(urlString: _url.relativeString) else {
return nil
}
_parseInfo = parseInfo
Expand All @@ -1004,7 +1045,7 @@ public struct URL: Equatable, Sendable, Hashable {
}
#endif
bookmarkDataIsStale = stale.boolValue
let parseInfo = Parser.parse(urlString: _url.relativeString, encodingInvalidCharacters: true)!
let parseInfo = URL.parse(urlString: _url.relativeString)!
_parseInfo = parseInfo
if parseInfo.scheme == nil {
_baseParseInfo = url?.absoluteURL._parseInfo
Expand Down Expand Up @@ -1229,15 +1270,13 @@ public struct URL: Equatable, Sendable, Hashable {
return nil
}

#if FOUNDATION_FRAMEWORK
// Linked-on-or-after check for apps which expect .host() to return nil
// for URLs like "https:///". The new .host() implementation returns
// an empty string because according to RFC 3986, a host always exists
// if there is an authority component, it just might be empty.
if Self.compatibility2 && encodedHost.isEmpty {
// According to RFC 3986, a host always exists if there is an authority
// component, it just might be empty. However, the old implementation
// of URL.host() returned nil for URLs like "https:///", and apps rely
// on this behavior, so keep it for bincompat.
if encodedHost.isEmpty, user() == nil, password() == nil, port == nil {
return nil
}
#endif

func requestedHost() -> String? {
let didPercentEncodeHost = hasAuthority ? _parseInfo.didPercentEncodeHost : _baseParseInfo?.didPercentEncodeHost ?? false
Expand Down Expand Up @@ -2063,7 +2102,7 @@ public struct URL: Equatable, Sendable, Hashable {
return
}
#endif
if let parseInfo = Parser.parse(urlString: _url.relativeString, encodingInvalidCharacters: true) {
if let parseInfo = URL.parse(urlString: _url.relativeString) {
_parseInfo = parseInfo
} else {
// Go to compatibility jail (allow `URL` as a dummy string container for `NSURL` instead of crashing)
Expand Down Expand Up @@ -2221,7 +2260,7 @@ extension URL {
#if !NO_FILESYSTEM
baseURL = baseURL ?? .currentDirectoryOrNil()
#endif
self.init(string: "", relativeTo: baseURL)!
self.init(string: "./", relativeTo: baseURL)!
return
}

Expand All @@ -2233,13 +2272,27 @@ extension URL {
#endif

#if FOUNDATION_FRAMEWORK
// Linked-on-or-after check for apps which incorrectly pass a full
// "file:" URL string. In the old implementation, this could work
// Linked-on-or-after check for apps which incorrectly pass a full URL
// string with a scheme. In the old implementation, this could work
// rarely if the app immediately called .appendingPathComponent(_:),
// which used to accidentally interpret a relative string starting
// with "file:" as an absolute file URL string.
if Self.compatibility3 && filePath.starts(with: "file:") {
filePath = String(filePath.dropFirst(5))
// which used to accidentally interpret a relative path starting with
// "scheme:" as an absolute "scheme:" URL string.
if Self.compatibility1 {
if filePath.utf8.starts(with: "file:".utf8) {
#if canImport(os)
URL.logger.fault("API MISUSE: URL(filePath:) called with a \"file:\" scheme. Input must only contain a path. Dropping \"file:\" scheme.")
#endif
filePath = String(filePath.dropFirst(5))._compressingSlashes()
} else if filePath.utf8.starts(with: "http:".utf8) || filePath.utf8.starts(with: "https:".utf8) {
#if canImport(os)
URL.logger.fault("API MISUSE: URL(filePath:) called with an HTTP URL string. Using URL(string:) instead.")
#endif
guard let httpURL = URL(string: filePath) else {
fatalError("API MISUSE: URL(filePath:) called with an HTTP URL string. URL(string:) returned nil.")
}
self = httpURL
return
}
}
#endif

Expand Down Expand Up @@ -2495,6 +2548,14 @@ extension URL {
#endif // NO_FILESYSTEM
}
#endif // FOUNDATION_FRAMEWORK

// The old .appending(component:) implementation did not actually percent-encode
// "/" for file URLs as the documentation suggests. Many apps accidentally use
// .appending(component: "path/with/slashes") instead of using .appending(path:),
// so changing this behavior would cause breakage.
if isFileURL {
return appending(path: component, directoryHint: directoryHint, encodingSlashes: false)
}
return appending(path: component, directoryHint: directoryHint, encodingSlashes: true)
}

Expand Down
4 changes: 2 additions & 2 deletions Sources/FoundationEssentials/URL/URLComponents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -676,7 +676,7 @@ public struct URLComponents: Hashable, Equatable, Sendable {
return CFURLCreateWithString(kCFAllocatorDefault, string as CFString, nil) as URL?
}
#endif
return URL(string: string, relativeTo: nil)
return URL(stringOrEmpty: string, relativeTo: nil)
}

/// Returns a URL created from the URLComponents relative to a base URL.
Expand All @@ -690,7 +690,7 @@ public struct URLComponents: Hashable, Equatable, Sendable {
return CFURLCreateWithString(kCFAllocatorDefault, string as CFString, base as CFURL) as URL?
}
#endif
return URL(string: string, relativeTo: base)
return URL(stringOrEmpty: string, relativeTo: base)
}

/// Returns a URL string created from the URLComponents.
Expand Down
Loading