Skip to content

Commit c88a3eb

Browse files
committed
(142446243) Compatibility behaviors for Swift URL (swiftlang#1113)
1 parent b50ea3d commit c88a3eb

File tree

4 files changed

+110
-37
lines changed

4 files changed

+110
-37
lines changed

Sources/FoundationEssentials/URL/URL.swift

+75-18
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,10 @@ internal func foundation_swift_url_enabled() -> Bool {
616616
internal func foundation_swift_url_enabled() -> Bool { return true }
617617
#endif
618618

619+
#if canImport(os)
620+
internal import os
621+
#endif
622+
619623
/// 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.
620624
///
621625
/// 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.
@@ -624,6 +628,12 @@ internal func foundation_swift_url_enabled() -> Bool { return true }
624628
@available(macOS 10.10, iOS 8.0, watchOS 2.0, tvOS 9.0, *)
625629
public struct URL: Equatable, Sendable, Hashable {
626630

631+
#if canImport(os)
632+
internal static let logger: Logger = {
633+
Logger(subsystem: "com.apple.foundation", category: "url")
634+
}()
635+
#endif
636+
627637
#if FOUNDATION_FRAMEWORK
628638

629639
private var _url: NSURL
@@ -763,6 +773,10 @@ public struct URL: Equatable, Sendable, Hashable {
763773
internal var _parseInfo: URLParseInfo!
764774
private var _baseParseInfo: URLParseInfo?
765775

776+
private static func parse(urlString: String, encodingInvalidCharacters: Bool = true) -> URLParseInfo? {
777+
return Parser.parse(urlString: urlString, encodingInvalidCharacters: encodingInvalidCharacters, compatibility: .allowEmptyScheme)
778+
}
779+
766780
internal init(parseInfo: URLParseInfo, relativeTo url: URL? = nil) {
767781
_parseInfo = parseInfo
768782
if parseInfo.scheme == nil {
@@ -773,6 +787,31 @@ public struct URL: Equatable, Sendable, Hashable {
773787
#endif // FOUNDATION_FRAMEWORK
774788
}
775789

790+
/// The public initializers don't allow the empty string, and we must maintain that behavior
791+
/// for compatibility. However, there are cases internally where we need to create a URL with
792+
/// an empty string, such as when `.deletingLastPathComponent()` of a single path
793+
/// component. This previously worked since `URL` just wrapped an `NSURL`, which
794+
/// allows the empty string.
795+
internal init?(stringOrEmpty: String, relativeTo url: URL? = nil) {
796+
#if FOUNDATION_FRAMEWORK
797+
guard foundation_swift_url_enabled() else {
798+
guard let inner = NSURL(string: stringOrEmpty, relativeTo: url) else { return nil }
799+
_url = URL._converted(from: inner)
800+
return
801+
}
802+
#endif // FOUNDATION_FRAMEWORK
803+
guard let parseInfo = URL.parse(urlString: stringOrEmpty) else {
804+
return nil
805+
}
806+
_parseInfo = parseInfo
807+
if parseInfo.scheme == nil {
808+
_baseParseInfo = url?.absoluteURL._parseInfo
809+
}
810+
#if FOUNDATION_FRAMEWORK
811+
_url = URL._nsURL(from: _parseInfo, baseParseInfo: _baseParseInfo)
812+
#endif // FOUNDATION_FRAMEWORK
813+
}
814+
776815
/// Initialize with string.
777816
///
778817
/// 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).
@@ -785,7 +824,7 @@ public struct URL: Equatable, Sendable, Hashable {
785824
return
786825
}
787826
#endif // FOUNDATION_FRAMEWORK
788-
guard let parseInfo = Parser.parse(urlString: string, encodingInvalidCharacters: true) else {
827+
guard let parseInfo = URL.parse(urlString: string) else {
789828
return nil
790829
}
791830
_parseInfo = parseInfo
@@ -798,14 +837,15 @@ public struct URL: Equatable, Sendable, Hashable {
798837
///
799838
/// 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).
800839
public init?(string: __shared String, relativeTo url: __shared URL?) {
840+
guard !string.isEmpty else { return nil }
801841
#if FOUNDATION_FRAMEWORK
802842
guard foundation_swift_url_enabled() else {
803-
guard !string.isEmpty, let inner = NSURL(string: string, relativeTo: url) else { return nil }
843+
guard let inner = NSURL(string: string, relativeTo: url) else { return nil }
804844
_url = URL._converted(from: inner)
805845
return
806846
}
807847
#endif // FOUNDATION_FRAMEWORK
808-
guard let parseInfo = Parser.parse(urlString: string, encodingInvalidCharacters: true) else {
848+
guard let parseInfo = URL.parse(urlString: string) else {
809849
return nil
810850
}
811851
_parseInfo = parseInfo
@@ -824,14 +864,15 @@ public struct URL: Equatable, Sendable, Hashable {
824864
/// If the URL string is still invalid after encoding, `nil` is returned.
825865
@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *)
826866
public init?(string: __shared String, encodingInvalidCharacters: Bool) {
867+
guard !string.isEmpty else { return nil }
827868
#if FOUNDATION_FRAMEWORK
828869
guard foundation_swift_url_enabled() else {
829-
guard !string.isEmpty, let inner = NSURL(string: string, encodingInvalidCharacters: encodingInvalidCharacters) else { return nil }
870+
guard let inner = NSURL(string: string, encodingInvalidCharacters: encodingInvalidCharacters) else { return nil }
830871
_url = URL._converted(from: inner)
831872
return
832873
}
833874
#endif // FOUNDATION_FRAMEWORK
834-
guard let parseInfo = Parser.parse(urlString: string, encodingInvalidCharacters: encodingInvalidCharacters) else {
875+
guard let parseInfo = URL.parse(urlString: string, encodingInvalidCharacters: encodingInvalidCharacters) else {
835876
return nil
836877
}
837878
_parseInfo = parseInfo
@@ -858,7 +899,7 @@ public struct URL: Equatable, Sendable, Hashable {
858899
}
859900
#endif
860901
let directoryHint: DirectoryHint = isDirectory ? .isDirectory : .notDirectory
861-
self.init(filePath: path, directoryHint: directoryHint, relativeTo: base)
902+
self.init(filePath: path.isEmpty ? "." : path, directoryHint: directoryHint, relativeTo: base)
862903
}
863904

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

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

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

923964
// NSURL(fileURLWithPath:) can return nil incorrectly for some malformed paths
@@ -941,24 +982,24 @@ public struct URL: Equatable, Sendable, Hashable {
941982
///
942983
/// 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.
943984
@available(macOS 10.11, iOS 9.0, watchOS 2.0, tvOS 9.0, *)
944-
public init?(dataRepresentation: __shared Data, relativeTo url: __shared URL?, isAbsolute: Bool = false) {
985+
public init?(dataRepresentation: __shared Data, relativeTo base: __shared URL?, isAbsolute: Bool = false) {
945986
guard !dataRepresentation.isEmpty else { return nil }
946987
#if FOUNDATION_FRAMEWORK
947988
guard foundation_swift_url_enabled() else {
948989
if isAbsolute {
949-
_url = URL._converted(from: NSURL(absoluteURLWithDataRepresentation: dataRepresentation, relativeTo: url))
990+
_url = URL._converted(from: NSURL(absoluteURLWithDataRepresentation: dataRepresentation, relativeTo: base))
950991
} else {
951-
_url = URL._converted(from: NSURL(dataRepresentation: dataRepresentation, relativeTo: url))
992+
_url = URL._converted(from: NSURL(dataRepresentation: dataRepresentation, relativeTo: base))
952993
}
953994
return
954995
}
955996
#endif
956997
var url: URL?
957998
if let string = String(data: dataRepresentation, encoding: .utf8) {
958-
url = URL(string: string, relativeTo: url)
999+
url = URL(stringOrEmpty: string, relativeTo: base)
9591000
}
9601001
if url == nil, let string = String(data: dataRepresentation, encoding: .isoLatin1) {
961-
url = URL(string: string, relativeTo: url)
1002+
url = URL(stringOrEmpty: string, relativeTo: base)
9621003
}
9631004
guard let url else {
9641005
return nil
@@ -983,7 +1024,7 @@ public struct URL: Equatable, Sendable, Hashable {
9831024
return
9841025
}
9851026
#endif
986-
guard let parseInfo = Parser.parse(urlString: _url.relativeString, encodingInvalidCharacters: true) else {
1027+
guard let parseInfo = URL.parse(urlString: _url.relativeString) else {
9871028
return nil
9881029
}
9891030
_parseInfo = parseInfo
@@ -1004,7 +1045,7 @@ public struct URL: Equatable, Sendable, Hashable {
10041045
}
10051046
#endif
10061047
bookmarkDataIsStale = stale.boolValue
1007-
let parseInfo = Parser.parse(urlString: _url.relativeString, encodingInvalidCharacters: true)!
1048+
let parseInfo = URL.parse(urlString: _url.relativeString)!
10081049
_parseInfo = parseInfo
10091050
if parseInfo.scheme == nil {
10101051
_baseParseInfo = url?.absoluteURL._parseInfo
@@ -1229,6 +1270,14 @@ public struct URL: Equatable, Sendable, Hashable {
12291270
return nil
12301271
}
12311272

1273+
// According to RFC 3986, a host always exists if there is an authority
1274+
// component, it just might be empty. However, the old implementation
1275+
// of URL.host() returned nil for URLs like "https:///", and apps rely
1276+
// on this behavior, so keep it for bincompat.
1277+
if encodedHost.isEmpty, user() == nil, password() == nil, port == nil {
1278+
return nil
1279+
}
1280+
12321281
func requestedHost() -> String? {
12331282
let didPercentEncodeHost = hasAuthority ? _parseInfo.didPercentEncodeHost : _baseParseInfo?.didPercentEncodeHost ?? false
12341283
if percentEncoded {
@@ -2053,7 +2102,7 @@ public struct URL: Equatable, Sendable, Hashable {
20532102
return
20542103
}
20552104
#endif
2056-
if let parseInfo = Parser.parse(urlString: _url.relativeString, encodingInvalidCharacters: true) {
2105+
if let parseInfo = URL.parse(urlString: _url.relativeString) {
20572106
_parseInfo = parseInfo
20582107
} else {
20592108
// Go to compatibility jail (allow `URL` as a dummy string container for `NSURL` instead of crashing)
@@ -2211,7 +2260,7 @@ extension URL {
22112260
#if !NO_FILESYSTEM
22122261
baseURL = baseURL ?? .currentDirectoryOrNil()
22132262
#endif
2214-
self.init(string: "", relativeTo: baseURL)!
2263+
self.init(string: "./", relativeTo: baseURL)!
22152264
return
22162265
}
22172266

@@ -2474,6 +2523,14 @@ extension URL {
24742523
#endif // NO_FILESYSTEM
24752524
}
24762525
#endif // FOUNDATION_FRAMEWORK
2526+
2527+
// The old .appending(component:) implementation did not actually percent-encode
2528+
// "/" for file URLs as the documentation suggests. Many apps accidentally use
2529+
// .appending(component: "path/with/slashes") instead of using .appending(path:),
2530+
// so changing this behavior would cause breakage.
2531+
if isFileURL {
2532+
return appending(path: component, directoryHint: directoryHint, encodingSlashes: false)
2533+
}
24772534
return appending(path: component, directoryHint: directoryHint, encodingSlashes: true)
24782535
}
24792536

Sources/FoundationEssentials/URL/URLComponents.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -676,7 +676,7 @@ public struct URLComponents: Hashable, Equatable, Sendable {
676676
return CFURLCreateWithString(kCFAllocatorDefault, string as CFString, nil) as URL?
677677
}
678678
#endif
679-
return URL(string: string, relativeTo: nil)
679+
return URL(stringOrEmpty: string, relativeTo: nil)
680680
}
681681

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

696696
/// Returns a URL string created from the URLComponents.

Sources/FoundationEssentials/URL/URLParser.swift

+24-10
Original file line numberDiff line numberDiff line change
@@ -137,10 +137,17 @@ internal enum URLParserKind {
137137
case RFC3986
138138
}
139139

140+
internal struct URLParserCompatibility: OptionSet {
141+
let rawValue: UInt8
142+
static let allowEmptyScheme = URLParserCompatibility(rawValue: 1 << 0)
143+
}
144+
140145
internal protocol URLParserProtocol {
141146
static var kind: URLParserKind { get }
142147

143148
static func parse(urlString: String, encodingInvalidCharacters: Bool) -> URLParseInfo?
149+
static func parse(urlString: String, encodingInvalidCharacters: Bool, compatibility: URLParserCompatibility) -> URLParseInfo?
150+
144151
static func validate(_ string: (some StringProtocol)?, component: URLComponents.Component) -> Bool
145152
static func validate(_ string: (some StringProtocol)?, component: URLComponents.Component, percentEncodingAllowed: Bool) -> Bool
146153

@@ -401,15 +408,18 @@ internal struct RFC3986Parser: URLParserProtocol {
401408
}
402409

403410
/// Fast path used during initial URL buffer parsing.
404-
private static func validate(schemeBuffer: Slice<UnsafeBufferPointer<UInt8>>) -> Bool {
405-
guard let first = schemeBuffer.first,
406-
first >= UInt8(ascii: "A"),
411+
private static func validate(schemeBuffer: Slice<UnsafeBufferPointer<UInt8>>, compatibility: URLParserCompatibility = .init()) -> Bool {
412+
guard let first = schemeBuffer.first else {
413+
return compatibility.contains(.allowEmptyScheme)
414+
}
415+
guard first >= UInt8(ascii: "A"),
407416
validate(buffer: schemeBuffer, component: .scheme, percentEncodingAllowed: false) else {
408417
return false
409418
}
410419
return true
411420
}
412421

422+
/// Only used by URLComponents, don't need to consider `URLParserCompatibility.allowEmptyScheme`
413423
private static func validate(scheme: some StringProtocol) -> Bool {
414424
// A valid scheme must start with an ALPHA character.
415425
// If first >= "A" and is in schemeAllowed, then first is ALPHA.
@@ -593,10 +603,14 @@ internal struct RFC3986Parser: URLParserProtocol {
593603
/// Parses a URL string into `URLParseInfo`, with the option to add (or skip) encoding of invalid characters.
594604
/// If `encodingInvalidCharacters` is `true`, this function handles encoding of invalid components.
595605
static func parse(urlString: String, encodingInvalidCharacters: Bool) -> URLParseInfo? {
606+
return parse(urlString: urlString, encodingInvalidCharacters: encodingInvalidCharacters, compatibility: .init())
607+
}
608+
609+
static func parse(urlString: String, encodingInvalidCharacters: Bool, compatibility: URLParserCompatibility) -> URLParseInfo? {
596610
#if os(Windows)
597611
let urlString = urlString.replacing(UInt8(ascii: "\\"), with: UInt8(ascii: "/"))
598612
#endif
599-
guard let parseInfo = parse(urlString: urlString) else {
613+
guard let parseInfo = parse(urlString: urlString, compatibility: compatibility) else {
600614
return nil
601615
}
602616

@@ -690,10 +704,10 @@ internal struct RFC3986Parser: URLParserProtocol {
690704

691705
/// Parses a URL string into its component parts and stores these ranges in a `URLParseInfo`.
692706
/// This function calls `parse(buffer:)`, then converts the buffer ranges into string ranges.
693-
private static func parse(urlString: String) -> URLParseInfo? {
707+
private static func parse(urlString: String, compatibility: URLParserCompatibility = .init()) -> URLParseInfo? {
694708
var string = urlString
695709
let bufferParseInfo = string.withUTF8 {
696-
parse(buffer: $0)
710+
parse(buffer: $0, compatibility: compatibility)
697711
}
698712
guard let bufferParseInfo else {
699713
return nil
@@ -726,7 +740,7 @@ internal struct RFC3986Parser: URLParserProtocol {
726740

727741
/// Parses a URL string into its component parts and stores these ranges in a `URLBufferParseInfo`.
728742
/// This function only parses based on delimiters and does not do any encoding.
729-
private static func parse(buffer: UnsafeBufferPointer<UInt8>) -> URLBufferParseInfo? {
743+
private static func parse(buffer: UnsafeBufferPointer<UInt8>, compatibility: URLParserCompatibility = .init()) -> URLBufferParseInfo? {
730744
// A URI is either:
731745
// 1. scheme ":" hier-part [ "?" query ] [ "#" fragment ]
732746
// 2. relative-ref
@@ -746,12 +760,12 @@ internal struct RFC3986Parser: URLParserProtocol {
746760
let v = buffer[currentIndex]
747761
if v == UInt8(ascii: ":") {
748762
// Scheme must be at least 1 character, otherwise this is a relative-ref.
749-
if currentIndex != buffer.startIndex {
763+
if currentIndex != buffer.startIndex || compatibility.contains(.allowEmptyScheme) {
750764
parseInfo.schemeRange = buffer.startIndex..<currentIndex
751765
currentIndex = buffer.index(after: currentIndex)
752766
if currentIndex == buffer.endIndex {
753767
guard let schemeRange = parseInfo.schemeRange,
754-
validate(schemeBuffer: buffer[schemeRange]) else {
768+
validate(schemeBuffer: buffer[schemeRange], compatibility: compatibility) else {
755769
return nil
756770
}
757771
// The string only contained a scheme, but the path always exists.
@@ -777,7 +791,7 @@ internal struct RFC3986Parser: URLParserProtocol {
777791
}
778792

779793
if let schemeRange = parseInfo.schemeRange {
780-
guard validate(schemeBuffer: buffer[schemeRange]) else {
794+
guard validate(schemeBuffer: buffer[schemeRange], compatibility: compatibility) else {
781795
return nil
782796
}
783797
}

0 commit comments

Comments
 (0)