Skip to content

[6.1] URL compatibility and bug fixes #1200

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 6 commits into from
Mar 5, 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
102 changes: 72 additions & 30 deletions Sources/FoundationEssentials/URL/URL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,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,26 +777,44 @@ 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).
public init?(string: __shared String) {
guard !string.isEmpty else { return nil }
#if FOUNDATION_FRAMEWORK
guard foundation_swift_url_enabled() else {
guard !string.isEmpty, let inner = NSURL(string: string) else { return nil }
guard let inner = NSURL(string: string) else { return nil }
_url = URL._converted(from: inner)
return
}
// Linked-on-or-after check for apps which pass an empty string.
// The new URL(string:) implementations allow the empty string
// as input since an empty path is valid and can be resolved
// against a base URL. This is shown in the RFC 3986 examples:
// https://datatracker.ietf.org/doc/html/rfc3986#section-5.4.1
if Self.compatibility1 && string.isEmpty {
return nil
}
#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 @@ -805,14 +827,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 @@ -831,14 +854,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 @@ -865,7 +889,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 @@ -884,7 +908,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 @@ -905,7 +929,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 @@ -924,7 +948,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 @@ -948,24 +972,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 @@ -990,7 +1014,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 @@ -1011,7 +1035,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 @@ -1089,7 +1113,9 @@ public struct URL: Equatable, Sendable, Hashable {
}

if let baseScheme = _baseParseInfo.scheme {
result.scheme = String(baseScheme)
// Scheme might be empty, which URL allows for compatibility,
// but URLComponents does not, so we force it internally.
result.forceScheme(String(baseScheme))
}

if hasAuthority {
Expand Down Expand Up @@ -1236,6 +1262,14 @@ public struct URL: Equatable, Sendable, Hashable {
return nil
}

// 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
}

func requestedHost() -> String? {
let didPercentEncodeHost = hasAuthority ? _parseInfo.didPercentEncodeHost : _baseParseInfo?.didPercentEncodeHost ?? false
if percentEncoded {
Expand Down Expand Up @@ -1456,7 +1490,7 @@ public struct URL: Equatable, Sendable, Hashable {
}
#endif
if _baseParseInfo != nil {
return absoluteURL.path(percentEncoded: percentEncoded)
return absoluteURL.relativePath(percentEncoded: percentEncoded)
}
if percentEncoded {
return String(_parseInfo.path)
Expand Down Expand Up @@ -1844,7 +1878,7 @@ public struct URL: Equatable, Sendable, Hashable {
var components = URLComponents(parseInfo: _parseInfo)
let newPath = components.percentEncodedPath.removingDotSegments
components.percentEncodedPath = newPath
return components.url(relativeTo: baseURL)!
return components.url(relativeTo: baseURL) ?? self
}

/// Standardizes the path of a file URL by removing dot segments.
Expand Down Expand Up @@ -2060,7 +2094,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 @@ -2218,7 +2252,7 @@ extension URL {
#if !NO_FILESYSTEM
baseURL = baseURL ?? .currentDirectoryOrNil()
#endif
self.init(string: "", relativeTo: baseURL)!
self.init(string: "./", relativeTo: baseURL)!
return
}

Expand Down Expand Up @@ -2481,6 +2515,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
45 changes: 39 additions & 6 deletions Sources/FoundationEssentials/URL/URLComponents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,12 @@ public struct URLComponents: Hashable, Equatable, Sendable {
return nil
}

mutating func setScheme(_ newValue: String?) throws {
mutating func setScheme(_ newValue: String?, force: Bool = false) throws {
reset(.scheme)
guard Parser.validate(newValue, component: .scheme) else {
throw InvalidComponentError.scheme
if !force {
guard Parser.validate(newValue, component: .scheme) else {
throw InvalidComponentError.scheme
}
}
_scheme = newValue
if encodedHost != nil {
Expand Down Expand Up @@ -364,6 +366,26 @@ public struct URLComponents: Hashable, Equatable, Sendable {
return ""
}

private var percentEncodedPathNoColon: String {
let p = percentEncodedPath
guard p.utf8.first(where: { $0 == ._colon || $0 == ._slash }) == ._colon else {
return p
}
if p.utf8.first == ._colon {
// In the rare case that an app relies on URL allowing an empty
// scheme and passes its URL string directly to URLComponents
// to modify other components, we need to return the path without
// encoding the colons.
return p
}
let firstSlash = p.utf8.firstIndex(of: ._slash) ?? p.endIndex
let colonEncodedSegment = Array(p[..<firstSlash].utf8).replacing(
[._colon],
with: [UInt8(ascii: "%"), UInt8(ascii: "3"), UInt8(ascii: "A")]
)
return String(decoding: colonEncodedSegment, as: UTF8.self) + p[firstSlash...]
}

mutating func setPercentEncodedPath(_ newValue: String) throws {
reset(.path)
guard Parser.validate(newValue, component: .path) else {
Expand Down Expand Up @@ -451,7 +473,13 @@ public struct URLComponents: Hashable, Equatable, Sendable {
// The parser already validated a special-case (e.g. addressbook:).
result += ":\(portString)"
}
result += percentEncodedPath
if result.isEmpty {
// We must percent-encode colons in the first path segment
// as they could be misinterpreted as a scheme separator.
result += percentEncodedPathNoColon
} else {
result += percentEncodedPath
}
if let percentEncodedQuery {
result += "?\(percentEncodedQuery)"
}
Expand Down Expand Up @@ -676,7 +704,7 @@ public struct URLComponents: Hashable, Equatable, Sendable {
return CFURLCreateWithString(kCFAllocatorDefault, string as CFString, nil) as URL?
}
#endif
return URL(string: string)
return URL(stringOrEmpty: string, relativeTo: nil)
}

/// Returns a URL created from the URLComponents relative to a base URL.
Expand All @@ -690,7 +718,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 All @@ -716,6 +744,11 @@ public struct URLComponents: Hashable, Equatable, Sendable {
}
}

/// Used by `URL` to allow empty scheme for compatibility.
internal mutating func forceScheme(_ scheme: String) {
try? components.setScheme(scheme, force: true)
}

#if FOUNDATION_FRAMEWORK
/// Throwing function used by `_NSSwiftURLComponents` to generate an exception for ObjC callers
internal mutating func setScheme(_ newValue: String?) throws {
Expand Down
Loading