diff --git a/Sources/FoundationEssentials/URL/URL_Swift.swift b/Sources/FoundationEssentials/URL/URL_Swift.swift index e694e7c0a..3b0559187 100644 --- a/Sources/FoundationEssentials/URL/URL_Swift.swift +++ b/Sources/FoundationEssentials/URL/URL_Swift.swift @@ -299,16 +299,18 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable { return builder.string } let baseParseInfo = baseURL._swiftURL?._parseInfo - let baseEncodedComponents = baseParseInfo?.encodedComponents ?? [] - if let baseUser = baseURL.user(percentEncoded: !baseEncodedComponents.contains(.user)) { + // If we aren't in the special case where we need the original + // string, always leave the base components encoded. + let baseComponentsToDecode = !original ? [] : baseParseInfo?.encodedComponents ?? [] + if let baseUser = baseURL.user(percentEncoded: !baseComponentsToDecode.contains(.user)) { builder.user = baseUser } - if let basePassword = baseURL.password(percentEncoded: !baseEncodedComponents.contains(.password)) { + if let basePassword = baseURL.password(percentEncoded: !baseComponentsToDecode.contains(.password)) { builder.password = basePassword } if let baseHost = baseParseInfo?.host { - builder.host = baseEncodedComponents.contains(.host) && baseParseInfo!.didPercentEncodeHost ? Parser.percentDecode(baseHost) : String(baseHost) - } else if let baseHost = baseURL.host(percentEncoded: !baseEncodedComponents.contains(.host)) { + builder.host = baseComponentsToDecode.contains(.host) && baseParseInfo!.didPercentEncodeHost ? Parser.percentDecode(baseHost) : String(baseHost) + } else if let baseHost = baseURL.host(percentEncoded: !baseComponentsToDecode.contains(.host)) { builder.host = baseHost } if let basePort = baseParseInfo?.portString { @@ -317,8 +319,8 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable { builder.portString = String(basePort) } if builder.path.isEmpty { - builder.path = baseURL.path(percentEncoded: !baseEncodedComponents.contains(.path)) - if builder.query == nil, let baseQuery = baseURL.query(percentEncoded: !baseEncodedComponents.contains(.query)) { + builder.path = baseURL.path(percentEncoded: !baseComponentsToDecode.contains(.path)) + if builder.query == nil, let baseQuery = baseURL.query(percentEncoded: !baseComponentsToDecode.contains(.query)) { builder.query = baseQuery } } else { @@ -327,7 +329,7 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable { } else if baseURL.hasAuthority && baseURL.path().isEmpty { "/" + builder.path } else { - baseURL.path(percentEncoded: !baseEncodedComponents.contains(.path)).merging(relativePath: builder.path) + baseURL.path(percentEncoded: !baseComponentsToDecode.contains(.path)).merging(relativePath: builder.path) } builder.path = newPath.removingDotSegments } diff --git a/Tests/FoundationEssentialsTests/URLTests.swift b/Tests/FoundationEssentialsTests/URLTests.swift index 8c49e3bcf..cb8a92be1 100644 --- a/Tests/FoundationEssentialsTests/URLTests.swift +++ b/Tests/FoundationEssentialsTests/URLTests.swift @@ -727,6 +727,66 @@ private struct URLTests { #expect(schemeRelative.relativePath == "") } + @Test func deletingLastPathComponentWithBase() throws { + let basePath = "/Users/foo-bar/Test1 Test2? Test3/Test4" + let baseURL = URL(filePath: basePath, directoryHint: .isDirectory) + let fileURL = URL(filePath: "../Test5.txt", directoryHint: .notDirectory, relativeTo: baseURL) + #expect(fileURL.path == "/Users/foo-bar/Test1 Test2? Test3/Test5.txt") + #expect(fileURL.deletingLastPathComponent().path == "/Users/foo-bar/Test1 Test2? Test3") + #expect(baseURL.deletingLastPathComponent().path == "/Users/foo-bar/Test1 Test2? Test3") + } + + @Test func encodedAbsoluteString() throws { + let base = URL(string: "http://user name:pass word@😂😂😂.com/pa th/p?qu ery#frag ment") + #expect(base?.absoluteString == "http://user%20name:pass%20word@xn--g28haa.com/pa%20th/p?qu%20ery#frag%20ment") + var url = URL(string: "relative", relativeTo: base) + #expect(url?.absoluteString == "http://user%20name:pass%20word@xn--g28haa.com/pa%20th/relative") + url = URL(string: "rela tive", relativeTo: base) + #expect(url?.absoluteString == "http://user%20name:pass%20word@xn--g28haa.com/pa%20th/rela%20tive") + url = URL(string: "relative?qu", relativeTo: base) + #expect(url?.absoluteString == "http://user%20name:pass%20word@xn--g28haa.com/pa%20th/relative?qu") + url = URL(string: "rela tive?q u", relativeTo: base) + #expect(url?.absoluteString == "http://user%20name:pass%20word@xn--g28haa.com/pa%20th/rela%20tive?q%20u") + + let fileBase = URL(filePath: "/Users/foo bar/more spaces/") + #expect(fileBase.absoluteString == "file:///Users/foo%20bar/more%20spaces/") + + url = URL(string: "relative", relativeTo: fileBase) + #expect(url?.absoluteString == "file:///Users/foo%20bar/more%20spaces/relative") + #expect(url?.path == "/Users/foo bar/more spaces/relative") + + url = URL(string: "rela tive", relativeTo: fileBase) + #expect(url?.absoluteString == "file:///Users/foo%20bar/more%20spaces/rela%20tive") + #expect(url?.path == "/Users/foo bar/more spaces/rela tive") + + // URL(string:) should count ? as the query delimiter + url = URL(string: "relative?query", relativeTo: fileBase) + #expect(url?.absoluteString == "file:///Users/foo%20bar/more%20spaces/relative?query") + #expect(url?.path == "/Users/foo bar/more spaces/relative") + + url = URL(string: "rela tive?qu ery", relativeTo: fileBase) + #expect(url?.absoluteString == "file:///Users/foo%20bar/more%20spaces/rela%20tive?qu%20ery") + #expect(url?.path == "/Users/foo bar/more spaces/rela tive") + + // URL(filePath:) should encode ? as part of the path + url = URL(filePath: "relative?query", relativeTo: fileBase) + #expect(url?.absoluteString == "file:///Users/foo%20bar/more%20spaces/relative%3Fquery") + #expect(url?.path == "/Users/foo bar/more spaces/relative?query") + + url = URL(filePath: "rela tive?qu ery", relativeTo: fileBase) + #expect(url?.absoluteString == "file:///Users/foo%20bar/more%20spaces/rela%20tive%3Fqu%20ery") + #expect(url?.path == "/Users/foo bar/more spaces/rela tive?qu ery") + + // URL(filePath:) should encode %3F as part of the path + url = URL(filePath: "relative%3Fquery", relativeTo: fileBase) + #expect(url?.absoluteString == "file:///Users/foo%20bar/more%20spaces/relative%253Fquery") + #expect(url?.path == "/Users/foo bar/more spaces/relative%3Fquery") + + url = URL(filePath: "rela tive%3Fqu ery", relativeTo: fileBase) + #expect(url?.absoluteString == "file:///Users/foo%20bar/more%20spaces/rela%20tive%253Fqu%20ery") + #expect(url?.path == "/Users/foo bar/more spaces/rela tive%3Fqu ery") + } + @Test func filePathDropsTrailingSlashes() throws { var url = URL(filePath: "/path/slashes///") #expect(url.path() == "/path/slashes///")