diff --git a/Sources/FoundationEssentials/URL/URL.swift b/Sources/FoundationEssentials/URL/URL.swift index be58fe64..e52d10e7 100644 --- a/Sources/FoundationEssentials/URL/URL.swift +++ b/Sources/FoundationEssentials/URL/URL.swift @@ -2226,7 +2226,15 @@ extension URL { pathToAppend = String(decoding: utf8, as: UTF8.self) } - if newPath.utf8.last != ._slash && pathToAppend.utf8.first != ._slash { + // If the current path is empty (relative), don't append a slash which + // would make the path absolute--unless we have an authority. + + // If we have an authority, we must add a slash to separate the path from the authority, + // e.g. URL("http://example.com").appending(path: "path") == "http://example.com/path" + + if newPath.isEmpty && !hasAuthority { + // Do nothing, path will be directly appended + } else if newPath.utf8.last != ._slash && pathToAppend.utf8.first != ._slash { newPath += "/" } else if newPath.utf8.last == ._slash && pathToAppend.utf8.first == ._slash { _ = newPath.popLast() diff --git a/Tests/FoundationEssentialsTests/URLTests.swift b/Tests/FoundationEssentialsTests/URLTests.swift index e935bf0c..d56cb8a5 100644 --- a/Tests/FoundationEssentialsTests/URLTests.swift +++ b/Tests/FoundationEssentialsTests/URLTests.swift @@ -23,6 +23,18 @@ import TestSupport @testable import Foundation #endif +private func checkBehavior(_ result: T, new: T, old: T) { + #if FOUNDATION_FRAMEWORK + if foundation_swift_url_enabled() { + XCTAssertEqual(result, new) + } else { + XCTAssertEqual(result, old) + } + #else + XCTAssertEqual(result, new) + #endif +} + final class URLTests : XCTestCase { func testURLBasics() throws { @@ -87,11 +99,7 @@ final class URLTests : XCTestCase { XCTAssertEqual(relativeURLWithBase.password(), baseURL.password()) XCTAssertEqual(relativeURLWithBase.host(), baseURL.host()) XCTAssertEqual(relativeURLWithBase.port, baseURL.port) - #if !FOUNDATION_FRAMEWORK_NSURL - XCTAssertEqual(relativeURLWithBase.path(), "/base/relative/path") - #else - XCTAssertEqual(relativeURLWithBase.path(), "relative/path") - #endif + checkBehavior(relativeURLWithBase.path(), new: "/base/relative/path", old: "relative/path") XCTAssertEqual(relativeURLWithBase.relativePath, "relative/path") XCTAssertEqual(relativeURLWithBase.query(), "query") XCTAssertEqual(relativeURLWithBase.fragment(), "fragment") @@ -565,13 +573,8 @@ final class URLTests : XCTestCase { // `appending(component:)` should explicitly treat `component` as a single // path component, meaning "/" should be encoded to "%2F" before appending appended = url.appending(component: slashComponent, directoryHint: .notDirectory) - #if FOUNDATION_FRAMEWORK_NSURL - XCTAssertEqual(appended.absoluteString, "file:///var/mobile/relative/with:slash") - XCTAssertEqual(appended.relativePath, "relative/with:slash") - #else - XCTAssertEqual(appended.absoluteString, "file:///var/mobile/relative/%2Fwith:slash") - XCTAssertEqual(appended.relativePath, "relative/%2Fwith:slash") - #endif + 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") appended = url.appendingPathComponent(component, isDirectory: false) XCTAssertEqual(appended.absoluteString, "file:///var/mobile/relative/no:slash") @@ -685,12 +688,49 @@ final class URLTests : XCTestCase { XCTAssertEqual(url.path(), "/path.foo/") url.append(path: "/////") url.deletePathExtension() - #if !FOUNDATION_FRAMEWORK_NSURL - XCTAssertEqual(url.path(), "/path/") - #else // Old behavior only searches the last empty component, so the extension isn't actually removed - XCTAssertEqual(url.path(), "/path.foo///") - #endif + checkBehavior(url.path(), new: "/path/", old: "/path.foo///") + } + + func testURLAppendingToEmptyPath() throws { + let baseURL = URL(filePath: "/base/directory", directoryHint: .isDirectory) + let emptyPathURL = URL(filePath: "", relativeTo: baseURL) + let url = emptyPathURL.appending(path: "main.swift") + // New behavior keeps the path relative without needing to insert "." + checkBehavior(url.relativePath, new: "main.swift", old: "./main.swift") + XCTAssertEqual(url.path, "/base/directory/main.swift") + + var example = try XCTUnwrap(URL(string: "https://example.com")) + XCTAssertEqual(example.host(), "example.com") + XCTAssertTrue(example.path().isEmpty) + + // Appending to an empty path should add a slash if an authority exists + // The appended path should never become part of the host + example.append(path: "foo") + XCTAssertEqual(example.host(), "example.com") + XCTAssertEqual(example.path(), "/foo") + XCTAssertEqual(example.absoluteString, "https://example.com/foo") + + var emptyHost = try XCTUnwrap(URL(string: "scheme://")) + XCTAssertTrue(emptyHost.host()?.isEmpty ?? true) + XCTAssertTrue(emptyHost.path().isEmpty) + + emptyHost.append(path: "foo") + XCTAssertTrue(emptyHost.host()?.isEmpty ?? true) + // Old behavior failed to append correctly to an empty host + // Modern parsers agree that "foo" relative to "scheme://" is "scheme:///foo" + checkBehavior(emptyHost.path(), new: "/foo", old: "") + checkBehavior(emptyHost.absoluteString, new: "scheme:///foo", old: "scheme://") + + var schemeOnly = try XCTUnwrap(URL(string: "scheme:")) + XCTAssertTrue(schemeOnly.host()?.isEmpty ?? true) + XCTAssertTrue(schemeOnly.path().isEmpty) + + schemeOnly.append(path: "foo") + XCTAssertTrue(schemeOnly.host()?.isEmpty ?? true) + // Old behavior appends to the string, but is missing the path + checkBehavior(schemeOnly.path(), new: "foo", old: "") + XCTAssertEqual(schemeOnly.absoluteString, "scheme:foo") } func testURLComponentsPercentEncodedUnencodedProperties() throws {