Skip to content

Commit 35b58c4

Browse files
authored
[6.1] URL compatibility and bug fixes (#1200)
* (141549683) Restore behavior of URL(string: "") returning nil (#1103) * (142076445) Allow URL.standardized to return an empty string URL (#1110) * (142076445) Allow URL.standardized to return an empty string URL * Add ?? self to prevent force-unwrap * (142446243) Compatibility behaviors for Swift URL (#1113) * (142589056) URLComponents.string should percent-encode colons in first path segment if needed (#1117) * (142667792) URL.absoluteString crashes if baseURL starts with colon (#1119) * (143159003) Don't encode colon if URLComponents path starts with colon (#1139)
1 parent 7a8547c commit 35b58c4

File tree

4 files changed

+188
-53
lines changed

4 files changed

+188
-53
lines changed

Sources/FoundationEssentials/URL/URL.swift

+72-30
Original file line numberDiff line numberDiff line change
@@ -763,6 +763,10 @@ public struct URL: Equatable, Sendable, Hashable {
763763
internal var _parseInfo: URLParseInfo!
764764
private var _baseParseInfo: URLParseInfo?
765765

766+
private static func parse(urlString: String, encodingInvalidCharacters: Bool = true) -> URLParseInfo? {
767+
return Parser.parse(urlString: urlString, encodingInvalidCharacters: encodingInvalidCharacters, compatibility: .allowEmptyScheme)
768+
}
769+
766770
internal init(parseInfo: URLParseInfo, relativeTo url: URL? = nil) {
767771
_parseInfo = parseInfo
768772
if parseInfo.scheme == nil {
@@ -773,26 +777,44 @@ public struct URL: Equatable, Sendable, Hashable {
773777
#endif // FOUNDATION_FRAMEWORK
774778
}
775779

780+
/// The public initializers don't allow the empty string, and we must maintain that behavior
781+
/// for compatibility. However, there are cases internally where we need to create a URL with
782+
/// an empty string, such as when `.deletingLastPathComponent()` of a single path
783+
/// component. This previously worked since `URL` just wrapped an `NSURL`, which
784+
/// allows the empty string.
785+
internal init?(stringOrEmpty: String, relativeTo url: URL? = nil) {
786+
#if FOUNDATION_FRAMEWORK
787+
guard foundation_swift_url_enabled() else {
788+
guard let inner = NSURL(string: stringOrEmpty, relativeTo: url) else { return nil }
789+
_url = URL._converted(from: inner)
790+
return
791+
}
792+
#endif // FOUNDATION_FRAMEWORK
793+
guard let parseInfo = URL.parse(urlString: stringOrEmpty) else {
794+
return nil
795+
}
796+
_parseInfo = parseInfo
797+
if parseInfo.scheme == nil {
798+
_baseParseInfo = url?.absoluteURL._parseInfo
799+
}
800+
#if FOUNDATION_FRAMEWORK
801+
_url = URL._nsURL(from: _parseInfo, baseParseInfo: _baseParseInfo)
802+
#endif // FOUNDATION_FRAMEWORK
803+
}
804+
776805
/// Initialize with string.
777806
///
778807
/// 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).
779808
public init?(string: __shared String) {
809+
guard !string.isEmpty else { return nil }
780810
#if FOUNDATION_FRAMEWORK
781811
guard foundation_swift_url_enabled() else {
782-
guard !string.isEmpty, let inner = NSURL(string: string) else { return nil }
812+
guard let inner = NSURL(string: string) else { return nil }
783813
_url = URL._converted(from: inner)
784814
return
785815
}
786-
// Linked-on-or-after check for apps which pass an empty string.
787-
// The new URL(string:) implementations allow the empty string
788-
// as input since an empty path is valid and can be resolved
789-
// against a base URL. This is shown in the RFC 3986 examples:
790-
// https://datatracker.ietf.org/doc/html/rfc3986#section-5.4.1
791-
if Self.compatibility1 && string.isEmpty {
792-
return nil
793-
}
794816
#endif // FOUNDATION_FRAMEWORK
795-
guard let parseInfo = Parser.parse(urlString: string, encodingInvalidCharacters: true) else {
817+
guard let parseInfo = URL.parse(urlString: string) else {
796818
return nil
797819
}
798820
_parseInfo = parseInfo
@@ -805,14 +827,15 @@ public struct URL: Equatable, Sendable, Hashable {
805827
///
806828
/// 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).
807829
public init?(string: __shared String, relativeTo url: __shared URL?) {
830+
guard !string.isEmpty else { return nil }
808831
#if FOUNDATION_FRAMEWORK
809832
guard foundation_swift_url_enabled() else {
810-
guard !string.isEmpty, let inner = NSURL(string: string, relativeTo: url) else { return nil }
833+
guard let inner = NSURL(string: string, relativeTo: url) else { return nil }
811834
_url = URL._converted(from: inner)
812835
return
813836
}
814837
#endif // FOUNDATION_FRAMEWORK
815-
guard let parseInfo = Parser.parse(urlString: string, encodingInvalidCharacters: true) else {
838+
guard let parseInfo = URL.parse(urlString: string) else {
816839
return nil
817840
}
818841
_parseInfo = parseInfo
@@ -831,14 +854,15 @@ public struct URL: Equatable, Sendable, Hashable {
831854
/// If the URL string is still invalid after encoding, `nil` is returned.
832855
@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *)
833856
public init?(string: __shared String, encodingInvalidCharacters: Bool) {
857+
guard !string.isEmpty else { return nil }
834858
#if FOUNDATION_FRAMEWORK
835859
guard foundation_swift_url_enabled() else {
836-
guard !string.isEmpty, let inner = NSURL(string: string, encodingInvalidCharacters: encodingInvalidCharacters) else { return nil }
860+
guard let inner = NSURL(string: string, encodingInvalidCharacters: encodingInvalidCharacters) else { return nil }
837861
_url = URL._converted(from: inner)
838862
return
839863
}
840864
#endif // FOUNDATION_FRAMEWORK
841-
guard let parseInfo = Parser.parse(urlString: string, encodingInvalidCharacters: encodingInvalidCharacters) else {
865+
guard let parseInfo = URL.parse(urlString: string, encodingInvalidCharacters: encodingInvalidCharacters) else {
842866
return nil
843867
}
844868
_parseInfo = parseInfo
@@ -865,7 +889,7 @@ public struct URL: Equatable, Sendable, Hashable {
865889
}
866890
#endif
867891
let directoryHint: DirectoryHint = isDirectory ? .isDirectory : .notDirectory
868-
self.init(filePath: path, directoryHint: directoryHint, relativeTo: base)
892+
self.init(filePath: path.isEmpty ? "." : path, directoryHint: directoryHint, relativeTo: base)
869893
}
870894

871895
/// Initializes a newly created file URL referencing the local file or directory at path, relative to a base URL.
@@ -884,7 +908,7 @@ public struct URL: Equatable, Sendable, Hashable {
884908
return
885909
}
886910
#endif
887-
self.init(filePath: path, directoryHint: .checkFileSystem, relativeTo: base)
911+
self.init(filePath: path.isEmpty ? "." : path, directoryHint: .checkFileSystem, relativeTo: base)
888912
}
889913

890914
/// Initializes a newly created file URL referencing the local file or directory at path.
@@ -905,7 +929,7 @@ public struct URL: Equatable, Sendable, Hashable {
905929
}
906930
#endif
907931
let directoryHint: DirectoryHint = isDirectory ? .isDirectory : .notDirectory
908-
self.init(filePath: path, directoryHint: directoryHint)
932+
self.init(filePath: path.isEmpty ? "." : path, directoryHint: directoryHint)
909933
}
910934

911935
/// Initializes a newly created file URL referencing the local file or directory at path.
@@ -924,7 +948,7 @@ public struct URL: Equatable, Sendable, Hashable {
924948
return
925949
}
926950
#endif
927-
self.init(filePath: path, directoryHint: .checkFileSystem)
951+
self.init(filePath: path.isEmpty ? "." : path, directoryHint: .checkFileSystem)
928952
}
929953

930954
// NSURL(fileURLWithPath:) can return nil incorrectly for some malformed paths
@@ -948,24 +972,24 @@ public struct URL: Equatable, Sendable, Hashable {
948972
///
949973
/// 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.
950974
@available(macOS 10.11, iOS 9.0, watchOS 2.0, tvOS 9.0, *)
951-
public init?(dataRepresentation: __shared Data, relativeTo url: __shared URL?, isAbsolute: Bool = false) {
975+
public init?(dataRepresentation: __shared Data, relativeTo base: __shared URL?, isAbsolute: Bool = false) {
952976
guard !dataRepresentation.isEmpty else { return nil }
953977
#if FOUNDATION_FRAMEWORK
954978
guard foundation_swift_url_enabled() else {
955979
if isAbsolute {
956-
_url = URL._converted(from: NSURL(absoluteURLWithDataRepresentation: dataRepresentation, relativeTo: url))
980+
_url = URL._converted(from: NSURL(absoluteURLWithDataRepresentation: dataRepresentation, relativeTo: base))
957981
} else {
958-
_url = URL._converted(from: NSURL(dataRepresentation: dataRepresentation, relativeTo: url))
982+
_url = URL._converted(from: NSURL(dataRepresentation: dataRepresentation, relativeTo: base))
959983
}
960984
return
961985
}
962986
#endif
963987
var url: URL?
964988
if let string = String(data: dataRepresentation, encoding: .utf8) {
965-
url = URL(string: string, relativeTo: url)
989+
url = URL(stringOrEmpty: string, relativeTo: base)
966990
}
967991
if url == nil, let string = String(data: dataRepresentation, encoding: .isoLatin1) {
968-
url = URL(string: string, relativeTo: url)
992+
url = URL(stringOrEmpty: string, relativeTo: base)
969993
}
970994
guard let url else {
971995
return nil
@@ -990,7 +1014,7 @@ public struct URL: Equatable, Sendable, Hashable {
9901014
return
9911015
}
9921016
#endif
993-
guard let parseInfo = Parser.parse(urlString: _url.relativeString, encodingInvalidCharacters: true) else {
1017+
guard let parseInfo = URL.parse(urlString: _url.relativeString) else {
9941018
return nil
9951019
}
9961020
_parseInfo = parseInfo
@@ -1011,7 +1035,7 @@ public struct URL: Equatable, Sendable, Hashable {
10111035
}
10121036
#endif
10131037
bookmarkDataIsStale = stale.boolValue
1014-
let parseInfo = Parser.parse(urlString: _url.relativeString, encodingInvalidCharacters: true)!
1038+
let parseInfo = URL.parse(urlString: _url.relativeString)!
10151039
_parseInfo = parseInfo
10161040
if parseInfo.scheme == nil {
10171041
_baseParseInfo = url?.absoluteURL._parseInfo
@@ -1089,7 +1113,9 @@ public struct URL: Equatable, Sendable, Hashable {
10891113
}
10901114

10911115
if let baseScheme = _baseParseInfo.scheme {
1092-
result.scheme = String(baseScheme)
1116+
// Scheme might be empty, which URL allows for compatibility,
1117+
// but URLComponents does not, so we force it internally.
1118+
result.forceScheme(String(baseScheme))
10931119
}
10941120

10951121
if hasAuthority {
@@ -1236,6 +1262,14 @@ public struct URL: Equatable, Sendable, Hashable {
12361262
return nil
12371263
}
12381264

1265+
// According to RFC 3986, a host always exists if there is an authority
1266+
// component, it just might be empty. However, the old implementation
1267+
// of URL.host() returned nil for URLs like "https:///", and apps rely
1268+
// on this behavior, so keep it for bincompat.
1269+
if encodedHost.isEmpty, user() == nil, password() == nil, port == nil {
1270+
return nil
1271+
}
1272+
12391273
func requestedHost() -> String? {
12401274
let didPercentEncodeHost = hasAuthority ? _parseInfo.didPercentEncodeHost : _baseParseInfo?.didPercentEncodeHost ?? false
12411275
if percentEncoded {
@@ -1456,7 +1490,7 @@ public struct URL: Equatable, Sendable, Hashable {
14561490
}
14571491
#endif
14581492
if _baseParseInfo != nil {
1459-
return absoluteURL.path(percentEncoded: percentEncoded)
1493+
return absoluteURL.relativePath(percentEncoded: percentEncoded)
14601494
}
14611495
if percentEncoded {
14621496
return String(_parseInfo.path)
@@ -1844,7 +1878,7 @@ public struct URL: Equatable, Sendable, Hashable {
18441878
var components = URLComponents(parseInfo: _parseInfo)
18451879
let newPath = components.percentEncodedPath.removingDotSegments
18461880
components.percentEncodedPath = newPath
1847-
return components.url(relativeTo: baseURL)!
1881+
return components.url(relativeTo: baseURL) ?? self
18481882
}
18491883

18501884
/// Standardizes the path of a file URL by removing dot segments.
@@ -2060,7 +2094,7 @@ public struct URL: Equatable, Sendable, Hashable {
20602094
return
20612095
}
20622096
#endif
2063-
if let parseInfo = Parser.parse(urlString: _url.relativeString, encodingInvalidCharacters: true) {
2097+
if let parseInfo = URL.parse(urlString: _url.relativeString) {
20642098
_parseInfo = parseInfo
20652099
} else {
20662100
// Go to compatibility jail (allow `URL` as a dummy string container for `NSURL` instead of crashing)
@@ -2218,7 +2252,7 @@ extension URL {
22182252
#if !NO_FILESYSTEM
22192253
baseURL = baseURL ?? .currentDirectoryOrNil()
22202254
#endif
2221-
self.init(string: "", relativeTo: baseURL)!
2255+
self.init(string: "./", relativeTo: baseURL)!
22222256
return
22232257
}
22242258

@@ -2481,6 +2515,14 @@ extension URL {
24812515
#endif // NO_FILESYSTEM
24822516
}
24832517
#endif // FOUNDATION_FRAMEWORK
2518+
2519+
// The old .appending(component:) implementation did not actually percent-encode
2520+
// "/" for file URLs as the documentation suggests. Many apps accidentally use
2521+
// .appending(component: "path/with/slashes") instead of using .appending(path:),
2522+
// so changing this behavior would cause breakage.
2523+
if isFileURL {
2524+
return appending(path: component, directoryHint: directoryHint, encodingSlashes: false)
2525+
}
24842526
return appending(path: component, directoryHint: directoryHint, encodingSlashes: true)
24852527
}
24862528

Sources/FoundationEssentials/URL/URLComponents.swift

+39-6
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,12 @@ public struct URLComponents: Hashable, Equatable, Sendable {
142142
return nil
143143
}
144144

145-
mutating func setScheme(_ newValue: String?) throws {
145+
mutating func setScheme(_ newValue: String?, force: Bool = false) throws {
146146
reset(.scheme)
147-
guard Parser.validate(newValue, component: .scheme) else {
148-
throw InvalidComponentError.scheme
147+
if !force {
148+
guard Parser.validate(newValue, component: .scheme) else {
149+
throw InvalidComponentError.scheme
150+
}
149151
}
150152
_scheme = newValue
151153
if encodedHost != nil {
@@ -364,6 +366,26 @@ public struct URLComponents: Hashable, Equatable, Sendable {
364366
return ""
365367
}
366368

369+
private var percentEncodedPathNoColon: String {
370+
let p = percentEncodedPath
371+
guard p.utf8.first(where: { $0 == ._colon || $0 == ._slash }) == ._colon else {
372+
return p
373+
}
374+
if p.utf8.first == ._colon {
375+
// In the rare case that an app relies on URL allowing an empty
376+
// scheme and passes its URL string directly to URLComponents
377+
// to modify other components, we need to return the path without
378+
// encoding the colons.
379+
return p
380+
}
381+
let firstSlash = p.utf8.firstIndex(of: ._slash) ?? p.endIndex
382+
let colonEncodedSegment = Array(p[..<firstSlash].utf8).replacing(
383+
[._colon],
384+
with: [UInt8(ascii: "%"), UInt8(ascii: "3"), UInt8(ascii: "A")]
385+
)
386+
return String(decoding: colonEncodedSegment, as: UTF8.self) + p[firstSlash...]
387+
}
388+
367389
mutating func setPercentEncodedPath(_ newValue: String) throws {
368390
reset(.path)
369391
guard Parser.validate(newValue, component: .path) else {
@@ -451,7 +473,13 @@ public struct URLComponents: Hashable, Equatable, Sendable {
451473
// The parser already validated a special-case (e.g. addressbook:).
452474
result += ":\(portString)"
453475
}
454-
result += percentEncodedPath
476+
if result.isEmpty {
477+
// We must percent-encode colons in the first path segment
478+
// as they could be misinterpreted as a scheme separator.
479+
result += percentEncodedPathNoColon
480+
} else {
481+
result += percentEncodedPath
482+
}
455483
if let percentEncodedQuery {
456484
result += "?\(percentEncodedQuery)"
457485
}
@@ -676,7 +704,7 @@ public struct URLComponents: Hashable, Equatable, Sendable {
676704
return CFURLCreateWithString(kCFAllocatorDefault, string as CFString, nil) as URL?
677705
}
678706
#endif
679-
return URL(string: string)
707+
return URL(stringOrEmpty: string, relativeTo: nil)
680708
}
681709

682710
/// Returns a URL created from the URLComponents relative to a base URL.
@@ -690,7 +718,7 @@ public struct URLComponents: Hashable, Equatable, Sendable {
690718
return CFURLCreateWithString(kCFAllocatorDefault, string as CFString, base as CFURL) as URL?
691719
}
692720
#endif
693-
return URL(string: string, relativeTo: base)
721+
return URL(stringOrEmpty: string, relativeTo: base)
694722
}
695723

696724
/// Returns a URL string created from the URLComponents.
@@ -716,6 +744,11 @@ public struct URLComponents: Hashable, Equatable, Sendable {
716744
}
717745
}
718746

747+
/// Used by `URL` to allow empty scheme for compatibility.
748+
internal mutating func forceScheme(_ scheme: String) {
749+
try? components.setScheme(scheme, force: true)
750+
}
751+
719752
#if FOUNDATION_FRAMEWORK
720753
/// Throwing function used by `_NSSwiftURLComponents` to generate an exception for ObjC callers
721754
internal mutating func setScheme(_ newValue: String?) throws {

0 commit comments

Comments
 (0)