Skip to content

Commit 7bcd5f0

Browse files
committed
(138059051) URL: Appending to an empty file path results in an absolute path
1 parent 8510e20 commit 7bcd5f0

File tree

2 files changed

+88
-33
lines changed

2 files changed

+88
-33
lines changed

Sources/FoundationEssentials/URL/URL.swift

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2214,9 +2214,8 @@ extension URL {
22142214
var path = String(path)
22152215
#endif
22162216

2217-
var newPath = relativePath()
22182217
var insertedSlash = false
2219-
if !newPath.isEmpty && path.utf8.first != ._slash {
2218+
if !relativePath().isEmpty && path.utf8.first != ._slash {
22202219
// Don't treat as first path segment when encoding
22212220
path = "/" + path
22222221
insertedSlash = true
@@ -2231,13 +2230,30 @@ extension URL {
22312230
pathToAppend = String(decoding: utf8, as: UTF8.self)
22322231
}
22332232

2234-
if newPath.utf8.last != ._slash && pathToAppend.utf8.first != ._slash {
2235-
newPath += "/"
2236-
} else if newPath.utf8.last == ._slash && pathToAppend.utf8.first == ._slash {
2237-
_ = newPath.popLast()
2233+
func appendedPath() -> String {
2234+
var currentPath = relativePath()
2235+
if currentPath.isEmpty && !hasAuthority {
2236+
guard _parseInfo.scheme == nil else {
2237+
// Scheme only, append directly to the empty path, e.g.
2238+
// URL("scheme:").appending(path: "path") == scheme:path
2239+
return pathToAppend
2240+
}
2241+
// No scheme or authority, treat the empty path as "."
2242+
currentPath = "."
2243+
}
2244+
2245+
// If currentPath is empty, pathToAppend is relative, and we have an authority,
2246+
// we must append a slash to separate the path from authority, which happens below.
2247+
2248+
if currentPath.utf8.last != ._slash && pathToAppend.utf8.first != ._slash {
2249+
currentPath += "/"
2250+
} else if currentPath.utf8.last == ._slash && pathToAppend.utf8.first == ._slash {
2251+
_ = currentPath.popLast()
2252+
}
2253+
return currentPath + pathToAppend
22382254
}
22392255

2240-
newPath += pathToAppend
2256+
var newPath = appendedPath()
22412257

22422258
let hasTrailingSlash = newPath.utf8.last == ._slash
22432259
let isDirectory: Bool

Tests/FoundationEssentialsTests/URLTests.swift

Lines changed: 65 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,18 @@ import TestSupport
2323
@testable import Foundation
2424
#endif
2525

26+
private func checkBehavior<T: Equatable>(_ result: T, new: T, old: T, file: StaticString = #filePath, line: UInt = #line) {
27+
#if FOUNDATION_FRAMEWORK
28+
if foundation_swift_url_enabled() {
29+
XCTAssertEqual(result, new, file: file, line: line)
30+
} else {
31+
XCTAssertEqual(result, old, file: file, line: line)
32+
}
33+
#else
34+
XCTAssertEqual(result, new, file: file, line: line)
35+
#endif
36+
}
37+
2638
final class URLTests : XCTestCase {
2739

2840
func testURLBasics() throws {
@@ -87,11 +99,7 @@ final class URLTests : XCTestCase {
8799
XCTAssertEqual(relativeURLWithBase.password(), baseURL.password())
88100
XCTAssertEqual(relativeURLWithBase.host(), baseURL.host())
89101
XCTAssertEqual(relativeURLWithBase.port, baseURL.port)
90-
#if !FOUNDATION_FRAMEWORK_NSURL
91-
XCTAssertEqual(relativeURLWithBase.path(), "/base/relative/path")
92-
#else
93-
XCTAssertEqual(relativeURLWithBase.path(), "relative/path")
94-
#endif
102+
checkBehavior(relativeURLWithBase.path(), new: "/base/relative/path", old: "relative/path")
95103
XCTAssertEqual(relativeURLWithBase.relativePath, "relative/path")
96104
XCTAssertEqual(relativeURLWithBase.query(), "query")
97105
XCTAssertEqual(relativeURLWithBase.fragment(), "fragment")
@@ -154,7 +162,7 @@ final class URLTests : XCTestCase {
154162
"http:g" : "http:g", // For strict parsers
155163
]
156164

157-
#if FOUNDATION_FRAMEWORK_NSURL
165+
#if FOUNDATION_FRAMEWORK
158166
let testsFailingWithoutSwiftURL = Set([
159167
"",
160168
"../../../g",
@@ -165,8 +173,8 @@ final class URLTests : XCTestCase {
165173
#endif
166174

167175
for test in tests {
168-
#if FOUNDATION_FRAMEWORK_NSURL
169-
if testsFailingWithoutSwiftURL.contains(test.key) {
176+
#if FOUNDATION_FRAMEWORK
177+
if !foundation_swift_url_enabled(), testsFailingWithoutSwiftURL.contains(test.key) {
170178
continue
171179
}
172180
#endif
@@ -178,8 +186,8 @@ final class URLTests : XCTestCase {
178186
}
179187

180188
func testURLPathAPIsResolveAgainstBase() throws {
181-
#if FOUNDATION_FRAMEWORK_NSURL
182-
try XCTSkipIf(true)
189+
#if FOUNDATION_FRAMEWORK
190+
try XCTSkipIf(!foundation_swift_url_enabled())
183191
#endif
184192
// Borrowing the same test cases from RFC 3986, but checking paths
185193
let base = URL(string: "http://a/b/c/d;p?q")
@@ -246,8 +254,8 @@ final class URLTests : XCTestCase {
246254
}
247255

248256
func testURLPathComponentsPercentEncodedSlash() throws {
249-
#if FOUNDATION_FRAMEWORK_NSURL
250-
try XCTSkipIf(true)
257+
#if FOUNDATION_FRAMEWORK
258+
try XCTSkipIf(!foundation_swift_url_enabled())
251259
#endif
252260

253261
var url = try XCTUnwrap(URL(string: "https://example.com/https%3A%2F%2Fexample.com"))
@@ -270,8 +278,8 @@ final class URLTests : XCTestCase {
270278
}
271279

272280
func testURLRootlessPath() throws {
273-
#if FOUNDATION_FRAMEWORK_NSURL
274-
try XCTSkipIf(true)
281+
#if FOUNDATION_FRAMEWORK
282+
try XCTSkipIf(!foundation_swift_url_enabled())
275283
#endif
276284

277285
let paths = ["", "path"]
@@ -565,13 +573,8 @@ final class URLTests : XCTestCase {
565573
// `appending(component:)` should explicitly treat `component` as a single
566574
// path component, meaning "/" should be encoded to "%2F" before appending
567575
appended = url.appending(component: slashComponent, directoryHint: .notDirectory)
568-
#if FOUNDATION_FRAMEWORK_NSURL
569-
XCTAssertEqual(appended.absoluteString, "file:///var/mobile/relative/with:slash")
570-
XCTAssertEqual(appended.relativePath, "relative/with:slash")
571-
#else
572-
XCTAssertEqual(appended.absoluteString, "file:///var/mobile/relative/%2Fwith:slash")
573-
XCTAssertEqual(appended.relativePath, "relative/%2Fwith:slash")
574-
#endif
576+
checkBehavior(appended.absoluteString, new: "file:///var/mobile/relative/%2Fwith:slash", old: "file:///var/mobile/relative/with:slash")
577+
checkBehavior(appended.relativePath, new: "relative/%2Fwith:slash", old: "relative/with:slash")
575578

576579
appended = url.appendingPathComponent(component, isDirectory: false)
577580
XCTAssertEqual(appended.absoluteString, "file:///var/mobile/relative/no:slash")
@@ -685,12 +688,48 @@ final class URLTests : XCTestCase {
685688
XCTAssertEqual(url.path(), "/path.foo/")
686689
url.append(path: "/////")
687690
url.deletePathExtension()
688-
#if !FOUNDATION_FRAMEWORK_NSURL
689-
XCTAssertEqual(url.path(), "/path/")
690-
#else
691691
// Old behavior only searches the last empty component, so the extension isn't actually removed
692-
XCTAssertEqual(url.path(), "/path.foo///")
693-
#endif
692+
checkBehavior(url.path(), new: "/path/", old: "/path.foo///")
693+
}
694+
695+
func testURLAppendingToEmptyPath() throws {
696+
let baseURL = URL(filePath: "/base/directory", directoryHint: .isDirectory)
697+
let emptyPathURL = URL(filePath: "", relativeTo: baseURL)
698+
let url = emptyPathURL.appending(path: "main.swift")
699+
XCTAssertEqual(url.relativePath, "./main.swift")
700+
XCTAssertEqual(url.path, "/base/directory/main.swift")
701+
702+
var example = try XCTUnwrap(URL(string: "https://example.com"))
703+
XCTAssertEqual(example.host(), "example.com")
704+
XCTAssertTrue(example.path().isEmpty)
705+
706+
// Appending to an empty path should add a slash if an authority exists
707+
// The appended path should never become part of the host
708+
example.append(path: "foo")
709+
XCTAssertEqual(example.host(), "example.com")
710+
XCTAssertEqual(example.path(), "/foo")
711+
XCTAssertEqual(example.absoluteString, "https://example.com/foo")
712+
713+
var emptyHost = try XCTUnwrap(URL(string: "scheme://"))
714+
XCTAssertTrue(emptyHost.host()?.isEmpty ?? true)
715+
XCTAssertTrue(emptyHost.path().isEmpty)
716+
717+
emptyHost.append(path: "foo")
718+
XCTAssertTrue(emptyHost.host()?.isEmpty ?? true)
719+
// Old behavior failed to append correctly to an empty host
720+
// Modern parsers agree that "foo" relative to "scheme://" is "scheme:///foo"
721+
checkBehavior(emptyHost.path(), new: "/foo", old: "")
722+
checkBehavior(emptyHost.absoluteString, new: "scheme:///foo", old: "scheme://")
723+
724+
var schemeOnly = try XCTUnwrap(URL(string: "scheme:"))
725+
XCTAssertTrue(schemeOnly.host()?.isEmpty ?? true)
726+
XCTAssertTrue(schemeOnly.path().isEmpty)
727+
728+
schemeOnly.append(path: "foo")
729+
XCTAssertTrue(schemeOnly.host()?.isEmpty ?? true)
730+
// Old behavior appends to the string, but is missing the path
731+
checkBehavior(schemeOnly.path(), new: "foo", old: "")
732+
XCTAssertEqual(schemeOnly.absoluteString, "scheme:foo")
694733
}
695734

696735
func testURLComponentsPercentEncodedUnencodedProperties() throws {

0 commit comments

Comments
 (0)