Skip to content

Commit 77f79ed

Browse files
authored
[6.0.x] URL path bug fixes (#969)
* (133878310) URL.fileSystemPath should drop all trailing slashes (#852) * (133882014) URL(filePath: path, directoryHint: .notDirectory) should strip trailing slashes (#867) * (137129292) URL(filePath:) should not treat "~" as absolute (#961) * (137068266) URL.fileSystemPath should strip leading slash for Windows drive letters (#964) * (137287143) URL path extension APIs should strip trailing slashes (#965)
1 parent d3d8499 commit 77f79ed

File tree

4 files changed

+234
-69
lines changed

4 files changed

+234
-69
lines changed

Sources/FoundationEssentials/String/String+Path.swift

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,11 @@ extension String {
194194
guard let lastDot = self.utf8.lastIndex(of: dot) else {
195195
return self
196196
}
197-
return String(self[..<lastDot])
197+
var result = String(self[..<lastDot])
198+
if utf8.last == ._slash {
199+
result += "/"
200+
}
201+
return result
198202
}
199203

200204
private func validatePathExtension(_ pathExtension: String) -> Bool {
@@ -214,7 +218,16 @@ extension String {
214218
guard validatePathExtension(pathExtension) else {
215219
return self
216220
}
217-
return self + ".\(pathExtension)"
221+
var result = self._droppingTrailingSlashes
222+
guard result != "/" else {
223+
// Path was all slashes
224+
return self + ".\(pathExtension)"
225+
}
226+
result += ".\(pathExtension)"
227+
if utf8.last == ._slash {
228+
result += "/"
229+
}
230+
return result
218231
}
219232

220233
internal var pathExtension: String {
@@ -366,7 +379,7 @@ extension String {
366379
return String(cString: output)
367380
}
368381

369-
#if !NO_FILESYSTEM
382+
#if !NO_FILESYSTEM
370383
internal static func homeDirectoryPath(forUser user: String? = nil) -> String {
371384
#if os(Windows)
372385
if let user {
@@ -529,8 +542,10 @@ extension String {
529542
#else
530543
return "/tmp/"
531544
#endif
532-
#endif
545+
#endif // os(Windows)
533546
}
547+
#endif // !NO_FILESYSTEM
548+
534549
/// Replaces any number of sequential `/`
535550
/// characters with /
536551
/// NOTE: Internal so it's testable
@@ -569,7 +584,7 @@ extension String {
569584
}
570585
}
571586

572-
private var _droppingTrailingSlashes: String {
587+
internal var _droppingTrailingSlashes: String {
573588
guard !self.isEmpty else {
574589
return self
575590
}
@@ -579,7 +594,9 @@ extension String {
579594
}
580595
return String(self[...lastNonSlash])
581596
}
582-
597+
598+
#if !NO_FILESYSTEM
599+
583600
static var NETWORK_PREFIX: String { #"\\"# }
584601

585602
private var _standardizingPath: String {
@@ -616,7 +633,8 @@ extension String {
616633
var standardizingPath: String {
617634
expandingTildeInPath._standardizingPath
618635
}
619-
#endif // !NO_FILESYSTEM
636+
637+
#endif // !NO_FILESYSTEM
620638

621639
// _NSPathComponents
622640
var pathComponents: [String] {

Sources/FoundationEssentials/URL/URL.swift

Lines changed: 83 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1319,13 +1319,32 @@ public struct URL: Equatable, Sendable, Hashable {
13191319
}
13201320
}
13211321

1322-
private static func fileSystemPath(for urlPath: String) -> String {
1323-
var result = urlPath
1324-
if result.count > 1 && result.utf8.last == UInt8(ascii: "/") {
1325-
_ = result.popLast()
1322+
private static func windowsPath(for posixPath: String) -> String {
1323+
let utf8 = posixPath.utf8
1324+
guard utf8.count >= 4 else {
1325+
return posixPath
1326+
}
1327+
// "C:\" is standardized to "/C:/" on initialization
1328+
let array = Array(utf8)
1329+
if array[0] == ._slash,
1330+
array[1].isAlpha,
1331+
array[2] == ._colon,
1332+
array[3] == ._slash {
1333+
return String(Substring(utf8.dropFirst()))
13261334
}
1335+
return posixPath
1336+
}
1337+
1338+
private static func fileSystemPath(for urlPath: String) -> String {
13271339
let charsToLeaveEncoded: Set<UInt8> = [._slash, 0]
1328-
return Parser.percentDecode(result, excluding: charsToLeaveEncoded) ?? ""
1340+
guard let posixPath = Parser.percentDecode(urlPath._droppingTrailingSlashes, excluding: charsToLeaveEncoded) else {
1341+
return ""
1342+
}
1343+
#if os(Windows)
1344+
return windowsPath(for: posixPath)
1345+
#else
1346+
return posixPath
1347+
#endif
13291348
}
13301349

13311350
var fileSystemPath: String {
@@ -2026,55 +2045,65 @@ extension URL {
20262045

20272046
#if !NO_FILESYSTEM
20282047
private static func isDirectory(_ path: String) -> Bool {
2029-
#if !FOUNDATION_FRAMEWORK
2048+
#if os(Windows)
2049+
let path = path.replacing(._slash, with: ._backslash)
2050+
#endif
2051+
#if !FOUNDATION_FRAMEWORK
20302052
var isDirectory: Bool = false
20312053
_ = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory)
20322054
return isDirectory
2033-
#else
2055+
#else
20342056
var isDirectory: ObjCBool = false
20352057
_ = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory)
20362058
return isDirectory.boolValue
2037-
#endif
2059+
#endif
20382060
}
20392061
#endif // !NO_FILESYSTEM
20402062

20412063
/// Checks if a file path is absolute and standardizes the inputted file path on Windows
2064+
/// Assumes the path only contains `/` as the path separator
20422065
internal static func isAbsolute(standardizing filePath: inout String) -> Bool {
2066+
if filePath.utf8.first == ._slash {
2067+
return true
2068+
}
20432069
#if os(Windows)
2044-
var isAbsolute = false
20452070
let utf8 = filePath.utf8
2046-
if utf8.first == ._backslash {
2047-
// Either an absolute path or a UNC path
2048-
isAbsolute = true
2049-
} else if utf8.count >= 3 {
2050-
// Check if this is a drive letter
2051-
let first = utf8.first!
2052-
let secondIndex = utf8.index(after: utf8.startIndex)
2053-
let second = utf8[secondIndex]
2054-
let thirdIndex = utf8.index(after: secondIndex)
2055-
let third = utf8[thirdIndex]
2056-
isAbsolute = (
2057-
first.isAlpha
2058-
&& (second == ._colon || second == ._pipe)
2059-
&& third == ._backslash
2060-
)
2061-
2062-
if isAbsolute {
2063-
// Standardize to "\[drive-letter]:\..."
2064-
if second == ._pipe {
2065-
var filePathArray = Array(utf8)
2066-
filePathArray[1] = ._colon
2067-
filePathArray.insert(._backslash, at: 0)
2068-
filePath = String(decoding: filePathArray, as: UTF8.self)
2069-
} else {
2070-
filePath = "\\" + filePath
2071-
}
2071+
guard utf8.count >= 3 else {
2072+
return false
2073+
}
2074+
// Check if this is a drive letter
2075+
let first = utf8.first!
2076+
let secondIndex = utf8.index(after: utf8.startIndex)
2077+
let second = utf8[secondIndex]
2078+
let thirdIndex = utf8.index(after: secondIndex)
2079+
let third = utf8[thirdIndex]
2080+
let isAbsolute = (
2081+
first.isAlpha
2082+
&& (second == ._colon || second == ._pipe)
2083+
&& third == ._slash
2084+
)
2085+
if isAbsolute {
2086+
// Standardize to "/[drive-letter]:/..."
2087+
if second == ._pipe {
2088+
var filePathArray = Array(utf8)
2089+
filePathArray[1] = ._colon
2090+
filePathArray.insert(._slash, at: 0)
2091+
filePath = String(decoding: filePathArray, as: UTF8.self)
2092+
} else {
2093+
filePath = "/" + filePath
20722094
}
20732095
}
2074-
#else
2075-
let isAbsolute = filePath.utf8.first == UInt8(ascii: "/") || filePath.utf8.first == UInt8(ascii: "~")
2076-
#endif
20772096
return isAbsolute
2097+
#else // os(Windows)
2098+
#if !NO_FILESYSTEM
2099+
// Expand the tilde if present
2100+
if filePath.utf8.first == UInt8(ascii: "~") {
2101+
filePath = filePath.expandingTildeInPath
2102+
}
2103+
#endif
2104+
// Make sure the expanded path is absolute
2105+
return filePath.utf8.first == ._slash
2106+
#endif // os(Windows)
20782107
}
20792108

20802109
/// Initializes a newly created file URL referencing the local file or directory at path, relative to a base URL.
@@ -2111,10 +2140,9 @@ extension URL {
21112140
}
21122141

21132142
#if os(Windows)
2114-
let slash = UInt8(ascii: "\\")
2115-
var filePath = path.replacing(UInt8(ascii: "/"), with: slash)
2143+
// Convert any "\" to "/" before storing the URL parse info
2144+
var filePath = path.replacing(._backslash, with: ._slash)
21162145
#else
2117-
let slash = UInt8(ascii: "/")
21182146
var filePath = path
21192147
#endif
21202148

@@ -2126,41 +2154,31 @@ extension URL {
21262154
}
21272155
#endif
21282156

2129-
func absoluteFilePath() -> String {
2130-
guard !isAbsolute, let baseURL else {
2131-
return filePath
2132-
}
2133-
let basePath = baseURL.path()
2134-
#if os(Windows)
2135-
let urlPath = filePath.replacing(UInt8(ascii: "\\"), with: UInt8(ascii: "/"))
2136-
return URL.fileSystemPath(for: basePath.merging(relativePath: urlPath)).replacing(UInt8(ascii: "/"), with: UInt8(ascii: "\\"))
2137-
#else
2138-
return URL.fileSystemPath(for: basePath.merging(relativePath: filePath))
2139-
#endif
2140-
}
2141-
21422157
let isDirectory: Bool
21432158
switch directoryHint {
21442159
case .isDirectory:
21452160
isDirectory = true
21462161
case .notDirectory:
2162+
filePath = filePath._droppingTrailingSlashes
21472163
isDirectory = false
21482164
case .checkFileSystem:
21492165
#if !NO_FILESYSTEM
2166+
func absoluteFilePath() -> String {
2167+
guard !isAbsolute, let baseURL else {
2168+
return filePath
2169+
}
2170+
let absolutePath = baseURL.path().merging(relativePath: filePath)
2171+
return URL.fileSystemPath(for: absolutePath)
2172+
}
21502173
isDirectory = URL.isDirectory(absoluteFilePath())
21512174
#else
2152-
isDirectory = filePath.utf8.last == slash
2175+
isDirectory = filePath.utf8.last == ._slash
21532176
#endif
21542177
case .inferFromPath:
2155-
isDirectory = filePath.utf8.last == slash
2178+
isDirectory = filePath.utf8.last == ._slash
21562179
}
21572180

2158-
#if os(Windows)
2159-
// Convert any "\" back to "/" before storing the URL parse info
2160-
filePath = filePath.replacing(UInt8(ascii: "\\"), with: UInt8(ascii: "/"))
2161-
#endif
2162-
2163-
if !filePath.isEmpty && filePath.utf8.last != UInt8(ascii: "/") && isDirectory {
2181+
if isDirectory && !filePath.isEmpty && filePath.utf8.last != ._slash {
21642182
filePath += "/"
21652183
}
21662184
var components = URLComponents()
@@ -2438,6 +2456,9 @@ extension URL {
24382456
guard var filePath = path else {
24392457
return nil
24402458
}
2459+
#if os(Windows)
2460+
filePath = filePath.replacing(._backslash, with: ._slash)
2461+
#endif
24412462
guard URL.isAbsolute(standardizing: &filePath) else {
24422463
return nil
24432464
}

Tests/FoundationEssentialsTests/StringTests.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -812,6 +812,19 @@ final class StringTests : XCTestCase {
812812
}
813813
}
814814

815+
func testAppendingPathExtension() {
816+
XCTAssertEqual("".appendingPathExtension("foo"), ".foo")
817+
XCTAssertEqual("/".appendingPathExtension("foo"), "/.foo")
818+
XCTAssertEqual("//".appendingPathExtension("foo"), "//.foo")
819+
XCTAssertEqual("/path".appendingPathExtension("foo"), "/path.foo")
820+
XCTAssertEqual("/path.zip".appendingPathExtension("foo"), "/path.zip.foo")
821+
XCTAssertEqual("/path/".appendingPathExtension("foo"), "/path.foo/")
822+
XCTAssertEqual("/path//".appendingPathExtension("foo"), "/path.foo/")
823+
XCTAssertEqual("path".appendingPathExtension("foo"), "path.foo")
824+
XCTAssertEqual("path/".appendingPathExtension("foo"), "path.foo/")
825+
XCTAssertEqual("path//".appendingPathExtension("foo"), "path.foo/")
826+
}
827+
815828
func testDeletingPathExtenstion() {
816829
XCTAssertEqual("".deletingPathExtension(), "")
817830
XCTAssertEqual("/".deletingPathExtension(), "/")
@@ -834,6 +847,15 @@ final class StringTests : XCTestCase {
834847
XCTAssertEqual("/foo.bar/bar.baz/baz.zip".deletingPathExtension(), "/foo.bar/bar.baz/baz")
835848
XCTAssertEqual("/.././.././a.zip".deletingPathExtension(), "/.././.././a")
836849
XCTAssertEqual("/.././.././.".deletingPathExtension(), "/.././.././.")
850+
851+
XCTAssertEqual("path.foo".deletingPathExtension(), "path")
852+
XCTAssertEqual("path.foo.zip".deletingPathExtension(), "path.foo")
853+
XCTAssertEqual("/path.foo".deletingPathExtension(), "/path")
854+
XCTAssertEqual("/path.foo.zip".deletingPathExtension(), "/path.foo")
855+
XCTAssertEqual("path.foo/".deletingPathExtension(), "path/")
856+
XCTAssertEqual("path.foo//".deletingPathExtension(), "path/")
857+
XCTAssertEqual("/path.foo/".deletingPathExtension(), "/path/")
858+
XCTAssertEqual("/path.foo//".deletingPathExtension(), "/path/")
837859
}
838860

839861
func test_dataUsingEncoding() {

0 commit comments

Comments
 (0)