From 5e31f0c61327d8aa2d3fcb67a273924ad353bf08 Mon Sep 17 00:00:00 2001 From: Jonathan Flat <50605158+jrflat@users.noreply.github.com> Date: Fri, 20 Dec 2024 12:02:57 -0700 Subject: [PATCH 1/6] (141549683) Restore behavior of URL(string: "") returning nil (#1103) --- Sources/FoundationEssentials/URL/URL.swift | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/Sources/FoundationEssentials/URL/URL.swift b/Sources/FoundationEssentials/URL/URL.swift index 294024f40..e5c1df745 100644 --- a/Sources/FoundationEssentials/URL/URL.swift +++ b/Sources/FoundationEssentials/URL/URL.swift @@ -777,20 +777,13 @@ 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) { + 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 { return nil From b50ea3dc9a6f55986330a665ac184b4f832c486b Mon Sep 17 00:00:00 2001 From: Jonathan Flat <50605158+jrflat@users.noreply.github.com> Date: Fri, 3 Jan 2025 11:45:35 -0700 Subject: [PATCH 2/6] (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 --- Sources/FoundationEssentials/URL/URL.swift | 2 +- Sources/FoundationEssentials/URL/URLComponents.swift | 2 +- Tests/FoundationEssentialsTests/URLTests.swift | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Sources/FoundationEssentials/URL/URL.swift b/Sources/FoundationEssentials/URL/URL.swift index e5c1df745..15605e925 100644 --- a/Sources/FoundationEssentials/URL/URL.swift +++ b/Sources/FoundationEssentials/URL/URL.swift @@ -1837,7 +1837,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. diff --git a/Sources/FoundationEssentials/URL/URLComponents.swift b/Sources/FoundationEssentials/URL/URLComponents.swift index f5ce53ae7..6eb3a6680 100644 --- a/Sources/FoundationEssentials/URL/URLComponents.swift +++ b/Sources/FoundationEssentials/URL/URLComponents.swift @@ -676,7 +676,7 @@ public struct URLComponents: Hashable, Equatable, Sendable { return CFURLCreateWithString(kCFAllocatorDefault, string as CFString, nil) as URL? } #endif - return URL(string: string) + return URL(string: string, relativeTo: nil) } /// Returns a URL created from the URLComponents relative to a base URL. diff --git a/Tests/FoundationEssentialsTests/URLTests.swift b/Tests/FoundationEssentialsTests/URLTests.swift index 0e2cb3517..83c8ab630 100644 --- a/Tests/FoundationEssentialsTests/URLTests.swift +++ b/Tests/FoundationEssentialsTests/URLTests.swift @@ -1425,6 +1425,12 @@ final class URLTests : XCTestCase { XCTAssertEqual(comp.path, "/my\u{0}path") } + func testURLStandardizedEmptyString() { + let url = URL(string: "../../../")! + let standardized = url.standardized + XCTAssertTrue(standardized.path().isEmpty) + } + #if FOUNDATION_FRAMEWORK func testURLComponentsBridging() { var nsURLComponents = NSURLComponents( From a5d5eb5666b1be52168b30f6be12d24b9549adef Mon Sep 17 00:00:00 2001 From: Jonathan Flat <50605158+jrflat@users.noreply.github.com> Date: Wed, 8 Jan 2025 11:34:13 -0700 Subject: [PATCH 3/6] (142446243) Compatibility behaviors for Swift URL (#1113) --- Sources/FoundationEssentials/URL/URL.swift | 83 +++++++++++++++---- .../URL/URLComponents.swift | 4 +- .../FoundationEssentials/URL/URLParser.swift | 34 +++++--- .../FoundationEssentialsTests/URLTests.swift | 16 ++-- 4 files changed, 100 insertions(+), 37 deletions(-) diff --git a/Sources/FoundationEssentials/URL/URL.swift b/Sources/FoundationEssentials/URL/URL.swift index 15605e925..6bd1dbbbc 100644 --- a/Sources/FoundationEssentials/URL/URL.swift +++ b/Sources/FoundationEssentials/URL/URL.swift @@ -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 { @@ -773,6 +777,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). @@ -785,7 +814,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 @@ -798,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 @@ -824,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 @@ -858,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. @@ -877,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. @@ -898,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. @@ -917,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 @@ -941,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 @@ -983,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 @@ -1004,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 @@ -1229,6 +1260,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 { @@ -2053,7 +2092,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) @@ -2211,7 +2250,7 @@ extension URL { #if !NO_FILESYSTEM baseURL = baseURL ?? .currentDirectoryOrNil() #endif - self.init(string: "", relativeTo: baseURL)! + self.init(string: "./", relativeTo: baseURL)! return } @@ -2474,6 +2513,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) } diff --git a/Sources/FoundationEssentials/URL/URLComponents.swift b/Sources/FoundationEssentials/URL/URLComponents.swift index 6eb3a6680..e0fc9d137 100644 --- a/Sources/FoundationEssentials/URL/URLComponents.swift +++ b/Sources/FoundationEssentials/URL/URLComponents.swift @@ -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. @@ -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. diff --git a/Sources/FoundationEssentials/URL/URLParser.swift b/Sources/FoundationEssentials/URL/URLParser.swift index 25a3a2e70..9938b0f43 100644 --- a/Sources/FoundationEssentials/URL/URLParser.swift +++ b/Sources/FoundationEssentials/URL/URLParser.swift @@ -137,10 +137,17 @@ internal enum URLParserKind { case RFC3986 } +internal struct URLParserCompatibility: OptionSet { + let rawValue: UInt8 + static let allowEmptyScheme = URLParserCompatibility(rawValue: 1 << 0) +} + internal protocol URLParserProtocol { static var kind: URLParserKind { get } static func parse(urlString: String, encodingInvalidCharacters: Bool) -> URLParseInfo? + static func parse(urlString: String, encodingInvalidCharacters: Bool, compatibility: URLParserCompatibility) -> URLParseInfo? + static func validate(_ string: (some StringProtocol)?, component: URLComponents.Component) -> Bool static func validate(_ string: (some StringProtocol)?, component: URLComponents.Component, percentEncodingAllowed: Bool) -> Bool @@ -401,15 +408,18 @@ internal struct RFC3986Parser: URLParserProtocol { } /// Fast path used during initial URL buffer parsing. - private static func validate(schemeBuffer: Slice>) -> Bool { - guard let first = schemeBuffer.first, - first >= UInt8(ascii: "A"), + private static func validate(schemeBuffer: Slice>, compatibility: URLParserCompatibility = .init()) -> Bool { + guard let first = schemeBuffer.first else { + return compatibility.contains(.allowEmptyScheme) + } + guard first >= UInt8(ascii: "A"), validate(buffer: schemeBuffer, component: .scheme, percentEncodingAllowed: false) else { return false } return true } + /// Only used by URLComponents, don't need to consider `URLParserCompatibility.allowEmptyScheme` private static func validate(scheme: some StringProtocol) -> Bool { // A valid scheme must start with an ALPHA character. // If first >= "A" and is in schemeAllowed, then first is ALPHA. @@ -593,10 +603,14 @@ internal struct RFC3986Parser: URLParserProtocol { /// Parses a URL string into `URLParseInfo`, with the option to add (or skip) encoding of invalid characters. /// If `encodingInvalidCharacters` is `true`, this function handles encoding of invalid components. static func parse(urlString: String, encodingInvalidCharacters: Bool) -> URLParseInfo? { + return parse(urlString: urlString, encodingInvalidCharacters: encodingInvalidCharacters, compatibility: .init()) + } + + static func parse(urlString: String, encodingInvalidCharacters: Bool, compatibility: URLParserCompatibility) -> URLParseInfo? { #if os(Windows) let urlString = urlString.replacing(UInt8(ascii: "\\"), with: UInt8(ascii: "/")) #endif - guard let parseInfo = parse(urlString: urlString) else { + guard let parseInfo = parse(urlString: urlString, compatibility: compatibility) else { return nil } @@ -690,10 +704,10 @@ internal struct RFC3986Parser: URLParserProtocol { /// Parses a URL string into its component parts and stores these ranges in a `URLParseInfo`. /// This function calls `parse(buffer:)`, then converts the buffer ranges into string ranges. - private static func parse(urlString: String) -> URLParseInfo? { + private static func parse(urlString: String, compatibility: URLParserCompatibility = .init()) -> URLParseInfo? { var string = urlString let bufferParseInfo = string.withUTF8 { - parse(buffer: $0) + parse(buffer: $0, compatibility: compatibility) } guard let bufferParseInfo else { return nil @@ -726,7 +740,7 @@ internal struct RFC3986Parser: URLParserProtocol { /// Parses a URL string into its component parts and stores these ranges in a `URLBufferParseInfo`. /// This function only parses based on delimiters and does not do any encoding. - private static func parse(buffer: UnsafeBufferPointer) -> URLBufferParseInfo? { + private static func parse(buffer: UnsafeBufferPointer, compatibility: URLParserCompatibility = .init()) -> URLBufferParseInfo? { // A URI is either: // 1. scheme ":" hier-part [ "?" query ] [ "#" fragment ] // 2. relative-ref @@ -746,12 +760,12 @@ internal struct RFC3986Parser: URLParserProtocol { let v = buffer[currentIndex] if v == UInt8(ascii: ":") { // Scheme must be at least 1 character, otherwise this is a relative-ref. - if currentIndex != buffer.startIndex { + if currentIndex != buffer.startIndex || compatibility.contains(.allowEmptyScheme) { parseInfo.schemeRange = buffer.startIndex.. 1) { // The trailing slash is stripped in .path for file system compatibility @@ -607,11 +607,13 @@ final class URLTests : XCTestCase { XCTAssertEqual(appended.absoluteString, "file:///var/mobile/relative/no:slash") XCTAssertEqual(appended.relativePath, "relative/no:slash") - // `appending(component:)` should explicitly treat `component` as a single - // path component, meaning "/" should be encoded to "%2F" before appending + // .appending(component:) should explicitly treat slashComponent as a single + // path component, meaning "/" should be encoded to "%2F" before appending. + // However, the old behavior didn't do this for file URLs, so we maintain the + // old behavior to prevent breakage. appended = url.appending(component: slashComponent, directoryHint: .notDirectory) - checkBehavior(appended.absoluteString, new: "file:///var/mobile/relative/%2Fwith:slash", old: "file:///var/mobile/relative/with:slash") - checkBehavior(appended.relativePath, new: "relative/%2Fwith:slash", old: "relative/with:slash") + XCTAssertEqual(appended.absoluteString, "file:///var/mobile/relative/with:slash") + XCTAssertEqual(appended.relativePath, "relative/with:slash") appended = url.appendingPathComponent(component, isDirectory: false) XCTAssertEqual(appended.absoluteString, "file:///var/mobile/relative/no:slash") @@ -687,7 +689,7 @@ final class URLTests : XCTestCase { checkBehavior(relative.path, new: "/", old: "/..") relative = URL(filePath: "", relativeTo: absolute) - checkBehavior(relative.relativePath, new: "", old: ".") + XCTAssertEqual(relative.relativePath, ".") XCTAssertTrue(relative.hasDirectoryPath) XCTAssertEqual(relative.path, "/absolute") From 352b1f8a865f350264da9c5ef7abc73c05a49f49 Mon Sep 17 00:00:00 2001 From: Jonathan Flat <50605158+jrflat@users.noreply.github.com> Date: Mon, 13 Jan 2025 12:33:35 -0700 Subject: [PATCH 4/6] (142589056) URLComponents.string should percent-encode colons in first path segment if needed (#1117) --- Sources/FoundationEssentials/URL/URL.swift | 2 +- .../URL/URLComponents.swift | 19 ++++++++++++++++++- .../FoundationEssentialsTests/URLTests.swift | 13 +++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/Sources/FoundationEssentials/URL/URL.swift b/Sources/FoundationEssentials/URL/URL.swift index 6bd1dbbbc..a2bf29e58 100644 --- a/Sources/FoundationEssentials/URL/URL.swift +++ b/Sources/FoundationEssentials/URL/URL.swift @@ -1488,7 +1488,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) diff --git a/Sources/FoundationEssentials/URL/URLComponents.swift b/Sources/FoundationEssentials/URL/URLComponents.swift index e0fc9d137..4df80f814 100644 --- a/Sources/FoundationEssentials/URL/URLComponents.swift +++ b/Sources/FoundationEssentials/URL/URLComponents.swift @@ -364,6 +364,17 @@ public struct URLComponents: Hashable, Equatable, Sendable { return "" } + private var percentEncodedPathNoColon: String { + guard percentEncodedPath.utf8.first(where: { $0 == ._colon || $0 == ._slash }) == ._colon else { + return percentEncodedPath + } + let colonEncodedPath = Array(percentEncodedPath.utf8).replacing( + [._colon], + with: [UInt8(ascii: "%"), UInt8(ascii: "3"), UInt8(ascii: "A")] + ) + return String(decoding: colonEncodedPath, as: UTF8.self) + } + mutating func setPercentEncodedPath(_ newValue: String) throws { reset(.path) guard Parser.validate(newValue, component: .path) else { @@ -451,7 +462,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)" } diff --git a/Tests/FoundationEssentialsTests/URLTests.swift b/Tests/FoundationEssentialsTests/URLTests.swift index 10088405b..fc3dcd7ee 100644 --- a/Tests/FoundationEssentialsTests/URLTests.swift +++ b/Tests/FoundationEssentialsTests/URLTests.swift @@ -1347,6 +1347,19 @@ final class URLTests : XCTestCase { comp = try XCTUnwrap(URLComponents(string: legalURLString)) XCTAssertEqual(comp.string, legalURLString) XCTAssertEqual(comp.percentEncodedPath, colonFirstPath) + + // Colons should be percent-encoded by URLComponents.string if + // they could be misinterpreted as a scheme separator. + + comp = URLComponents() + comp.percentEncodedPath = "not%20a%20scheme:" + XCTAssertEqual(comp.string, "not%20a%20scheme%3A") + + // These would fail if we did not percent-encode the colon. + // .string should always produce a valid URL string, or nil. + + XCTAssertNotNil(URL(string: comp.string!)) + XCTAssertNotNil(URLComponents(string: comp.string!)) } func testURLComponentsInvalidPaths() { From b215b9aa0ea03de24f304f8e953d226320b09e6a Mon Sep 17 00:00:00 2001 From: Jonathan Flat <50605158+jrflat@users.noreply.github.com> Date: Tue, 21 Jan 2025 12:36:28 -0800 Subject: [PATCH 5/6] (142667792) URL.absoluteString crashes if baseURL starts with colon (#1119) --- Sources/FoundationEssentials/URL/URL.swift | 4 +++- .../FoundationEssentials/URL/URLComponents.swift | 13 ++++++++++--- Tests/FoundationEssentialsTests/URLTests.swift | 15 +++++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/Sources/FoundationEssentials/URL/URL.swift b/Sources/FoundationEssentials/URL/URL.swift index a2bf29e58..39ddb6bc0 100644 --- a/Sources/FoundationEssentials/URL/URL.swift +++ b/Sources/FoundationEssentials/URL/URL.swift @@ -1113,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 { diff --git a/Sources/FoundationEssentials/URL/URLComponents.swift b/Sources/FoundationEssentials/URL/URLComponents.swift index 4df80f814..d2d156c3c 100644 --- a/Sources/FoundationEssentials/URL/URLComponents.swift +++ b/Sources/FoundationEssentials/URL/URLComponents.swift @@ -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 { @@ -733,6 +735,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 { diff --git a/Tests/FoundationEssentialsTests/URLTests.swift b/Tests/FoundationEssentialsTests/URLTests.swift index fc3dcd7ee..5df879112 100644 --- a/Tests/FoundationEssentialsTests/URLTests.swift +++ b/Tests/FoundationEssentialsTests/URLTests.swift @@ -966,6 +966,21 @@ final class URLTests : XCTestCase { XCTAssertEqual(schemeOnly.absoluteString, "scheme:foo") } + func testURLEmptySchemeCompatibility() throws { + var url = try XCTUnwrap(URL(string: ":memory:")) + XCTAssertEqual(url.scheme, "") + + let base = try XCTUnwrap(URL(string: "://home")) + XCTAssertEqual(base.host(), "home") + + url = try XCTUnwrap(URL(string: "/path", relativeTo: base)) + XCTAssertEqual(url.scheme, "") + XCTAssertEqual(url.host(), "home") + XCTAssertEqual(url.path, "/path") + XCTAssertEqual(url.absoluteString, "://home/path") + XCTAssertEqual(url.absoluteURL.scheme, "") + } + func testURLComponentsPercentEncodedUnencodedProperties() throws { var comp = URLComponents() From 2ee7c89bbfde01dfdf216207855d8f341dcc1420 Mon Sep 17 00:00:00 2001 From: Jonathan Flat <50605158+jrflat@users.noreply.github.com> Date: Fri, 24 Jan 2025 14:53:29 -0700 Subject: [PATCH 6/6] (143159003) Don't encode colon if URLComponents path starts with colon (#1139) --- .../URL/URLComponents.swift | 17 +++++++++++++---- Tests/FoundationEssentialsTests/URLTests.swift | 10 ++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/Sources/FoundationEssentials/URL/URLComponents.swift b/Sources/FoundationEssentials/URL/URLComponents.swift index d2d156c3c..43bd493be 100644 --- a/Sources/FoundationEssentials/URL/URLComponents.swift +++ b/Sources/FoundationEssentials/URL/URLComponents.swift @@ -367,14 +367,23 @@ public struct URLComponents: Hashable, Equatable, Sendable { } private var percentEncodedPathNoColon: String { - guard percentEncodedPath.utf8.first(where: { $0 == ._colon || $0 == ._slash }) == ._colon else { - return percentEncodedPath + let p = percentEncodedPath + guard p.utf8.first(where: { $0 == ._colon || $0 == ._slash }) == ._colon else { + return p } - let colonEncodedPath = Array(percentEncodedPath.utf8).replacing( + 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[..