Skip to content

Commit 3eef139

Browse files
committed
(138059777) URL.deletingLastPathComponent() should append .. in special cases
1 parent 7bb4100 commit 3eef139

File tree

2 files changed

+179
-5
lines changed

2 files changed

+179
-5
lines changed

Sources/FoundationEssentials/URL/URL.swift

+29-5
Original file line numberDiff line numberDiff line change
@@ -1357,6 +1357,11 @@ public struct URL: Equatable, Sendable, Hashable {
13571357
return URL.fileSystemPath(for: path())
13581358
}
13591359

1360+
/// True if the URL's relative path would resolve against a base URL path
1361+
private var pathResolvesAgainstBase: Bool {
1362+
return _parseInfo.scheme == nil && !hasAuthority && relativePath().utf8.first != ._slash
1363+
}
1364+
13601365
/// Returns the path component of the URL if present, otherwise returns an empty string.
13611366
///
13621367
/// - note: This function will resolve against the base `URL`.
@@ -1649,7 +1654,9 @@ public struct URL: Equatable, Sendable, Hashable {
16491654
/// Returns a URL constructed by removing the last path component of self.
16501655
///
16511656
/// This function may either remove a path component or append `/..`.
1652-
/// If the URL has an empty path (e.g., `http://www.example.com`), then this function will return the URL unchanged.
1657+
/// If the URL has an empty path that is not resolved against a base URL
1658+
/// (e.g., `http://www.example.com`),
1659+
/// then this function will return the URL unchanged.
16531660
public func deletingLastPathComponent() -> URL {
16541661
#if FOUNDATION_FRAMEWORK
16551662
guard foundation_swift_url_enabled() else {
@@ -1658,13 +1665,30 @@ public struct URL: Equatable, Sendable, Hashable {
16581665
return result
16591666
}
16601667
#endif
1661-
guard !relativePath().isEmpty else { return self }
1662-
var components = URLComponents(parseInfo: _parseInfo)
1663-
var newPath = components.percentEncodedPath.deletingLastPathComponent()
1668+
let path = relativePath()
1669+
let shouldAppendDotDot = (
1670+
pathResolvesAgainstBase && (
1671+
path.isEmpty
1672+
|| path.lastPathComponent == "."
1673+
|| path.lastPathComponent == ".."
1674+
)
1675+
)
1676+
1677+
var newPath = path
1678+
if newPath.lastPathComponent != ".." {
1679+
newPath = newPath.deletingLastPathComponent()
1680+
}
1681+
if shouldAppendDotDot {
1682+
newPath = newPath.appendingPathComponent("..")
1683+
}
1684+
if newPath.isEmpty && pathResolvesAgainstBase {
1685+
newPath = "."
1686+
}
16641687
// .deletingLastPathComponent() removes the trailing "/", but we know it's a directory
1665-
if !newPath.isEmpty, newPath.utf8.last != UInt8(ascii: "/") {
1688+
if !newPath.isEmpty && newPath.utf8.last != ._slash {
16661689
newPath += "/"
16671690
}
1691+
var components = URLComponents(parseInfo: _parseInfo)
16681692
components.percentEncodedPath = newPath
16691693
return components.url(relativeTo: baseURL)!
16701694
}

Tests/FoundationEssentialsTests/URLTests.swift

+150
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,156 @@ final class URLTests : XCTestCase {
586586
XCTAssertEqual(appended.relativePath, "relative/with:slash")
587587
}
588588

589+
func testURLDeletingLastPathComponent() throws {
590+
var absolute = URL(filePath: "/absolute/path", directoryHint: .notDirectory)
591+
// Note: .relativePath strips the trailing slash for compatibility
592+
XCTAssertEqual(absolute.relativePath, "/absolute/path")
593+
XCTAssertFalse(absolute.hasDirectoryPath)
594+
595+
absolute.deleteLastPathComponent()
596+
XCTAssertEqual(absolute.relativePath, "/absolute")
597+
XCTAssertTrue(absolute.hasDirectoryPath)
598+
599+
absolute.deleteLastPathComponent()
600+
XCTAssertEqual(absolute.relativePath, "/")
601+
XCTAssertTrue(absolute.hasDirectoryPath)
602+
603+
// The old .deleteLastPathComponent() implementation appends ".." to the
604+
// root directory "/", resulting in "/../". This resolves back to "/".
605+
// The new implementation simply leaves "/" as-is.
606+
absolute.deleteLastPathComponent()
607+
checkBehavior(absolute.relativePath, new: "/", old: "/..")
608+
XCTAssertTrue(absolute.hasDirectoryPath)
609+
610+
absolute.append(path: "absolute", directoryHint: .isDirectory)
611+
checkBehavior(absolute.path, new: "/absolute", old: "/../absolute")
612+
613+
// Reset `var absolute` to "/absolute" to prevent having
614+
// a "/../" prefix in all the old expectations.
615+
absolute = URL(filePath: "/absolute", directoryHint: .isDirectory)
616+
617+
var relative = URL(filePath: "relative/path", directoryHint: .notDirectory, relativeTo: absolute)
618+
XCTAssertEqual(relative.relativePath, "relative/path")
619+
XCTAssertFalse(relative.hasDirectoryPath)
620+
XCTAssertEqual(relative.path, "/absolute/relative/path")
621+
622+
relative.deleteLastPathComponent()
623+
XCTAssertEqual(relative.relativePath, "relative")
624+
XCTAssertTrue(relative.hasDirectoryPath)
625+
XCTAssertEqual(relative.path, "/absolute/relative")
626+
627+
relative.deleteLastPathComponent()
628+
XCTAssertEqual(relative.relativePath, ".")
629+
XCTAssertTrue(relative.hasDirectoryPath)
630+
XCTAssertEqual(relative.path, "/absolute")
631+
632+
relative.deleteLastPathComponent()
633+
XCTAssertEqual(relative.relativePath, "..")
634+
XCTAssertTrue(relative.hasDirectoryPath)
635+
XCTAssertEqual(relative.path, "/")
636+
637+
relative.deleteLastPathComponent()
638+
XCTAssertEqual(relative.relativePath, "../..")
639+
XCTAssertTrue(relative.hasDirectoryPath)
640+
checkBehavior(relative.path, new:"/", old: "/..")
641+
642+
relative.append(path: "path", directoryHint: .isDirectory)
643+
XCTAssertEqual(relative.relativePath, "../../path")
644+
XCTAssertTrue(relative.hasDirectoryPath)
645+
checkBehavior(relative.path, new: "/path", old: "/../path")
646+
647+
relative.deleteLastPathComponent()
648+
XCTAssertEqual(relative.relativePath, "../..")
649+
XCTAssertTrue(relative.hasDirectoryPath)
650+
checkBehavior(relative.path, new: "/", old: "/..")
651+
652+
relative = URL(filePath: "", relativeTo: absolute)
653+
checkBehavior(relative.relativePath, new: "", old: ".")
654+
XCTAssertTrue(relative.hasDirectoryPath)
655+
XCTAssertEqual(relative.path, "/absolute")
656+
657+
relative.deleteLastPathComponent()
658+
XCTAssertEqual(relative.relativePath, "..")
659+
XCTAssertTrue(relative.hasDirectoryPath)
660+
XCTAssertEqual(relative.path, "/")
661+
662+
relative.deleteLastPathComponent()
663+
XCTAssertEqual(relative.relativePath, "../..")
664+
XCTAssertTrue(relative.hasDirectoryPath)
665+
checkBehavior(relative.path, new: "/", old: "/..")
666+
667+
relative = URL(filePath: "relative/./", relativeTo: absolute)
668+
// According to RFC 3986, "." and ".." segments should not be removed
669+
// until the path is resolved against the base URL (when calling .path)
670+
checkBehavior(relative.relativePath, new: "relative/.", old: "relative")
671+
XCTAssertTrue(relative.hasDirectoryPath)
672+
XCTAssertEqual(relative.path, "/absolute/relative")
673+
674+
relative.deleteLastPathComponent()
675+
checkBehavior(relative.relativePath, new: "relative/..", old: ".")
676+
XCTAssertTrue(relative.hasDirectoryPath)
677+
XCTAssertEqual(relative.path, "/absolute")
678+
679+
relative = URL(filePath: "relative/.", directoryHint: .isDirectory, relativeTo: absolute)
680+
checkBehavior(relative.relativePath, new: "relative/.", old: "relative")
681+
XCTAssertTrue(relative.hasDirectoryPath)
682+
XCTAssertEqual(relative.path, "/absolute/relative")
683+
684+
relative.deleteLastPathComponent()
685+
checkBehavior(relative.relativePath, new: "relative/..", old: ".")
686+
XCTAssertTrue(relative.hasDirectoryPath)
687+
XCTAssertEqual(relative.path, "/absolute")
688+
689+
relative = URL(filePath: "relative/..", relativeTo: absolute)
690+
XCTAssertEqual(relative.relativePath, "relative/..")
691+
checkBehavior(relative.hasDirectoryPath, new: true, old: false)
692+
XCTAssertEqual(relative.path, "/absolute")
693+
694+
relative.deleteLastPathComponent()
695+
XCTAssertEqual(relative.relativePath, "relative/../..")
696+
XCTAssertTrue(relative.hasDirectoryPath)
697+
XCTAssertEqual(relative.path, "/")
698+
699+
relative = URL(filePath: "relative/..", directoryHint: .isDirectory, relativeTo: absolute)
700+
XCTAssertEqual(relative.relativePath, "relative/..")
701+
XCTAssertTrue(relative.hasDirectoryPath)
702+
XCTAssertEqual(relative.path, "/absolute")
703+
704+
relative.deleteLastPathComponent()
705+
XCTAssertEqual(relative.relativePath, "relative/../..")
706+
XCTAssertTrue(relative.hasDirectoryPath)
707+
XCTAssertEqual(relative.path, "/")
708+
709+
var url = try XCTUnwrap(URL(string: "scheme://host.with.no.path"))
710+
XCTAssertTrue(url.path().isEmpty)
711+
712+
url.deleteLastPathComponent()
713+
XCTAssertEqual(url.absoluteString, "scheme://host.with.no.path")
714+
XCTAssertTrue(url.path().isEmpty)
715+
716+
let unusedBase = URL(string: "base://url")
717+
url = try XCTUnwrap(URL(string: "scheme://host.with.no.path", relativeTo: unusedBase))
718+
XCTAssertEqual(url.absoluteString, "scheme://host.with.no.path")
719+
XCTAssertTrue(url.path().isEmpty)
720+
721+
url.deleteLastPathComponent()
722+
XCTAssertEqual(url.absoluteString, "scheme://host.with.no.path")
723+
XCTAssertTrue(url.path().isEmpty)
724+
725+
var schemeRelative = try XCTUnwrap(URL(string: "scheme:relative/path"))
726+
// Bug in the old implementation where a relative path is not recognized
727+
checkBehavior(schemeRelative.relativePath, new: "relative/path", old: "")
728+
729+
schemeRelative.deleteLastPathComponent()
730+
checkBehavior(schemeRelative.relativePath, new: "relative", old: "")
731+
732+
schemeRelative.deleteLastPathComponent()
733+
XCTAssertEqual(schemeRelative.relativePath, "")
734+
735+
schemeRelative.deleteLastPathComponent()
736+
XCTAssertEqual(schemeRelative.relativePath, "")
737+
}
738+
589739
func testURLFilePathDropsTrailingSlashes() throws {
590740
var url = URL(filePath: "/path/slashes///")
591741
XCTAssertEqual(url.path(), "/path/slashes///")

0 commit comments

Comments
 (0)