diff --git a/Sources/FoundationEssentials/URL/URL.swift b/Sources/FoundationEssentials/URL/URL.swift index 4cf6084fd..1246f32aa 100644 --- a/Sources/FoundationEssentials/URL/URL.swift +++ b/Sources/FoundationEssentials/URL/URL.swift @@ -1498,7 +1498,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 ea162af2c..e91ddbd14 100644 --- a/Tests/FoundationEssentialsTests/URLTests.swift +++ b/Tests/FoundationEssentialsTests/URLTests.swift @@ -1327,6 +1327,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() {