From a5329ebc7a9d0cf1e13304ea66cb8923967c106d Mon Sep 17 00:00:00 2001 From: Jonathan Flat <50605158+jrflat@users.noreply.github.com> Date: Fri, 16 Aug 2024 09:32:42 -0700 Subject: [PATCH 1/5] (133878310) URL.fileSystemPath should drop all trailing slashes (#852) --- .../FoundationEssentials/String/String+Path.swift | 15 ++++++++++----- Sources/FoundationEssentials/URL/URL.swift | 6 +----- Tests/FoundationEssentialsTests/URLTests.swift | 15 +++++++++++++++ 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/Sources/FoundationEssentials/String/String+Path.swift b/Sources/FoundationEssentials/String/String+Path.swift index 7047b9be8..06d0bd29f 100644 --- a/Sources/FoundationEssentials/String/String+Path.swift +++ b/Sources/FoundationEssentials/String/String+Path.swift @@ -366,7 +366,7 @@ extension String { return String(cString: output) } - #if !NO_FILESYSTEM +#if !NO_FILESYSTEM internal static func homeDirectoryPath(forUser user: String? = nil) -> String { #if os(Windows) if let user { @@ -529,8 +529,10 @@ extension String { #else return "/tmp/" #endif -#endif +#endif // os(Windows) } +#endif // !NO_FILESYSTEM + /// Replaces any number of sequential `/` /// characters with / /// NOTE: Internal so it's testable @@ -569,7 +571,7 @@ extension String { } } - private var _droppingTrailingSlashes: String { + internal var _droppingTrailingSlashes: String { guard !self.isEmpty else { return self } @@ -579,7 +581,9 @@ extension String { } return String(self[...lastNonSlash]) } - + +#if !NO_FILESYSTEM + static var NETWORK_PREFIX: String { #"\\"# } private var _standardizingPath: String { @@ -616,7 +620,8 @@ extension String { var standardizingPath: String { expandingTildeInPath._standardizingPath } - #endif // !NO_FILESYSTEM + +#endif // !NO_FILESYSTEM // _NSPathComponents var pathComponents: [String] { diff --git a/Sources/FoundationEssentials/URL/URL.swift b/Sources/FoundationEssentials/URL/URL.swift index 22cd44515..251a1c3a4 100644 --- a/Sources/FoundationEssentials/URL/URL.swift +++ b/Sources/FoundationEssentials/URL/URL.swift @@ -1320,12 +1320,8 @@ public struct URL: Equatable, Sendable, Hashable { } private static func fileSystemPath(for urlPath: String) -> String { - var result = urlPath - if result.count > 1 && result.utf8.last == UInt8(ascii: "/") { - _ = result.popLast() - } let charsToLeaveEncoded: Set = [._slash, 0] - return Parser.percentDecode(result, excluding: charsToLeaveEncoded) ?? "" + return Parser.percentDecode(urlPath._droppingTrailingSlashes, excluding: charsToLeaveEncoded) ?? "" } var fileSystemPath: String { diff --git a/Tests/FoundationEssentialsTests/URLTests.swift b/Tests/FoundationEssentialsTests/URLTests.swift index ca2dec76c..d1a04ee02 100644 --- a/Tests/FoundationEssentialsTests/URLTests.swift +++ b/Tests/FoundationEssentialsTests/URLTests.swift @@ -571,6 +571,21 @@ final class URLTests : XCTestCase { XCTAssertEqual(appended.relativePath, "relative/with:slash") } + func testURLFilePathDropsTrailingSlashes() throws { + var url = URL(filePath: "/path/slashes///") + XCTAssertEqual(url.path(), "/path/slashes///") + // TODO: Update this once .fileSystemPath uses backslashes for Windows + XCTAssertEqual(url.fileSystemPath, "/path/slashes") + + url = URL(filePath: "/path/slashes/") + XCTAssertEqual(url.path(), "/path/slashes/") + XCTAssertEqual(url.fileSystemPath, "/path/slashes") + + url = URL(filePath: "/path/slashes") + XCTAssertEqual(url.path(), "/path/slashes") + XCTAssertEqual(url.fileSystemPath, "/path/slashes") + } + func testURLHostRetainsIDNAEncoding() throws { let url = URL(string: "ftp://user:password@*.xn--poema-9qae5a.com.br:4343/cat.txt")! XCTAssertEqual(url.host, "*.xn--poema-9qae5a.com.br") From dc1ce5dd4fcab9accf52b339b57e4da1c1dd0cf1 Mon Sep 17 00:00:00 2001 From: Jonathan Flat <50605158+jrflat@users.noreply.github.com> Date: Mon, 19 Aug 2024 11:19:35 -0700 Subject: [PATCH 2/5] (133882014) URL(filePath: path, directoryHint: .notDirectory) should strip trailing slashes (#867) --- Sources/FoundationEssentials/URL/URL.swift | 60 +++++++++---------- .../FoundationEssentialsTests/URLTests.swift | 36 +++++++++++ 2 files changed, 64 insertions(+), 32 deletions(-) diff --git a/Sources/FoundationEssentials/URL/URL.swift b/Sources/FoundationEssentials/URL/URL.swift index 251a1c3a4..28abb3d98 100644 --- a/Sources/FoundationEssentials/URL/URL.swift +++ b/Sources/FoundationEssentials/URL/URL.swift @@ -2022,24 +2022,28 @@ extension URL { #if !NO_FILESYSTEM private static func isDirectory(_ path: String) -> Bool { -#if !FOUNDATION_FRAMEWORK + #if os(Windows) + let path = path.replacing(._slash, with: ._backslash) + #endif + #if !FOUNDATION_FRAMEWORK var isDirectory: Bool = false _ = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) return isDirectory -#else + #else var isDirectory: ObjCBool = false _ = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) return isDirectory.boolValue -#endif + #endif } #endif // !NO_FILESYSTEM /// Checks if a file path is absolute and standardizes the inputted file path on Windows + /// Assumes the path only contains `/` as the path separator internal static func isAbsolute(standardizing filePath: inout String) -> Bool { #if os(Windows) var isAbsolute = false let utf8 = filePath.utf8 - if utf8.first == ._backslash { + if utf8.first == ._slash { // Either an absolute path or a UNC path isAbsolute = true } else if utf8.count >= 3 { @@ -2052,18 +2056,18 @@ extension URL { isAbsolute = ( first.isAlpha && (second == ._colon || second == ._pipe) - && third == ._backslash + && third == ._slash ) if isAbsolute { - // Standardize to "\[drive-letter]:\..." + // Standardize to "/[drive-letter]:/..." if second == ._pipe { var filePathArray = Array(utf8) filePathArray[1] = ._colon - filePathArray.insert(._backslash, at: 0) + filePathArray.insert(._slash, at: 0) filePath = String(decoding: filePathArray, as: UTF8.self) } else { - filePath = "\\" + filePath + filePath = "/" + filePath } } } @@ -2107,10 +2111,9 @@ extension URL { } #if os(Windows) - let slash = UInt8(ascii: "\\") - var filePath = path.replacing(UInt8(ascii: "/"), with: slash) + // Convert any "\" to "/" before storing the URL parse info + var filePath = path.replacing(._backslash, with: ._slash) #else - let slash = UInt8(ascii: "/") var filePath = path #endif @@ -2122,41 +2125,31 @@ extension URL { } #endif - func absoluteFilePath() -> String { - guard !isAbsolute, let baseURL else { - return filePath - } - let basePath = baseURL.path() - #if os(Windows) - let urlPath = filePath.replacing(UInt8(ascii: "\\"), with: UInt8(ascii: "/")) - return URL.fileSystemPath(for: basePath.merging(relativePath: urlPath)).replacing(UInt8(ascii: "/"), with: UInt8(ascii: "\\")) - #else - return URL.fileSystemPath(for: basePath.merging(relativePath: filePath)) - #endif - } - let isDirectory: Bool switch directoryHint { case .isDirectory: isDirectory = true case .notDirectory: + filePath = filePath._droppingTrailingSlashes isDirectory = false case .checkFileSystem: #if !NO_FILESYSTEM + func absoluteFilePath() -> String { + guard !isAbsolute, let baseURL else { + return filePath + } + let absolutePath = baseURL.path().merging(relativePath: filePath) + return URL.fileSystemPath(for: absolutePath) + } isDirectory = URL.isDirectory(absoluteFilePath()) #else - isDirectory = filePath.utf8.last == slash + isDirectory = filePath.utf8.last == ._slash #endif case .inferFromPath: - isDirectory = filePath.utf8.last == slash + isDirectory = filePath.utf8.last == ._slash } - #if os(Windows) - // Convert any "\" back to "/" before storing the URL parse info - filePath = filePath.replacing(UInt8(ascii: "\\"), with: UInt8(ascii: "/")) - #endif - - if !filePath.isEmpty && filePath.utf8.last != UInt8(ascii: "/") && isDirectory { + if isDirectory && !filePath.isEmpty && filePath.utf8.last != ._slash { filePath += "/" } var components = URLComponents() @@ -2434,6 +2427,9 @@ extension URL { guard var filePath = path else { return nil } + #if os(Windows) + filePath = filePath.replacing(._backslash, with: ._slash) + #endif guard URL.isAbsolute(standardizing: &filePath) else { return nil } diff --git a/Tests/FoundationEssentialsTests/URLTests.swift b/Tests/FoundationEssentialsTests/URLTests.swift index d1a04ee02..360afbea9 100644 --- a/Tests/FoundationEssentialsTests/URLTests.swift +++ b/Tests/FoundationEssentialsTests/URLTests.swift @@ -586,6 +586,42 @@ final class URLTests : XCTestCase { XCTAssertEqual(url.fileSystemPath, "/path/slashes") } + func testURLNotDirectoryHintStripsTrailingSlash() throws { + // Supply a path with a trailing slash but say it's not a direcotry + var url = URL(filePath: "/path/", directoryHint: .notDirectory) + XCTAssertFalse(url.hasDirectoryPath) + XCTAssertEqual(url.path(), "/path") + + url = URL(fileURLWithPath: "/path/", isDirectory: false) + XCTAssertFalse(url.hasDirectoryPath) + XCTAssertEqual(url.path(), "/path") + + url = URL(filePath: "/path///", directoryHint: .notDirectory) + XCTAssertFalse(url.hasDirectoryPath) + XCTAssertEqual(url.path(), "/path") + + url = URL(fileURLWithPath: "/path///", isDirectory: false) + XCTAssertFalse(url.hasDirectoryPath) + XCTAssertEqual(url.path(), "/path") + + // With .checkFileSystem, don't modify the path for a non-existent file + url = URL(filePath: "/my/non/existent/path/", directoryHint: .checkFileSystem) + XCTAssertTrue(url.hasDirectoryPath) + XCTAssertEqual(url.path(), "/my/non/existent/path/") + + url = URL(fileURLWithPath: "/my/non/existent/path/") + XCTAssertTrue(url.hasDirectoryPath) + XCTAssertEqual(url.path(), "/my/non/existent/path/") + + url = URL(filePath: "/my/non/existent/path", directoryHint: .checkFileSystem) + XCTAssertFalse(url.hasDirectoryPath) + XCTAssertEqual(url.path(), "/my/non/existent/path") + + url = URL(fileURLWithPath: "/my/non/existent/path") + XCTAssertFalse(url.hasDirectoryPath) + XCTAssertEqual(url.path(), "/my/non/existent/path") + } + func testURLHostRetainsIDNAEncoding() throws { let url = URL(string: "ftp://user:password@*.xn--poema-9qae5a.com.br:4343/cat.txt")! XCTAssertEqual(url.host, "*.xn--poema-9qae5a.com.br") From 0889797220449506bff1ec46da301d25162a6d3c Mon Sep 17 00:00:00 2001 From: Jonathan Flat <50605158+jrflat@users.noreply.github.com> Date: Thu, 3 Oct 2024 13:55:26 -0700 Subject: [PATCH 3/5] (137129292) URL(filePath:) should not treat "~" as absolute (#961) --- Sources/FoundationEssentials/URL/URL.swift | 66 ++++++++++--------- .../FoundationEssentialsTests/URLTests.swift | 20 ++++++ 2 files changed, 56 insertions(+), 30 deletions(-) diff --git a/Sources/FoundationEssentials/URL/URL.swift b/Sources/FoundationEssentials/URL/URL.swift index 28abb3d98..20e2e43a0 100644 --- a/Sources/FoundationEssentials/URL/URL.swift +++ b/Sources/FoundationEssentials/URL/URL.swift @@ -2040,41 +2040,47 @@ extension URL { /// Checks if a file path is absolute and standardizes the inputted file path on Windows /// Assumes the path only contains `/` as the path separator internal static func isAbsolute(standardizing filePath: inout String) -> Bool { + if filePath.utf8.first == ._slash { + return true + } #if os(Windows) - var isAbsolute = false let utf8 = filePath.utf8 - if utf8.first == ._slash { - // Either an absolute path or a UNC path - isAbsolute = true - } else if utf8.count >= 3 { - // Check if this is a drive letter - let first = utf8.first! - let secondIndex = utf8.index(after: utf8.startIndex) - let second = utf8[secondIndex] - let thirdIndex = utf8.index(after: secondIndex) - let third = utf8[thirdIndex] - isAbsolute = ( - first.isAlpha - && (second == ._colon || second == ._pipe) - && third == ._slash - ) - - if isAbsolute { - // Standardize to "/[drive-letter]:/..." - if second == ._pipe { - var filePathArray = Array(utf8) - filePathArray[1] = ._colon - filePathArray.insert(._slash, at: 0) - filePath = String(decoding: filePathArray, as: UTF8.self) - } else { - filePath = "/" + filePath - } + guard utf8.count >= 3 else { + return false + } + // Check if this is a drive letter + let first = utf8.first! + let secondIndex = utf8.index(after: utf8.startIndex) + let second = utf8[secondIndex] + let thirdIndex = utf8.index(after: secondIndex) + let third = utf8[thirdIndex] + let isAbsolute = ( + first.isAlpha + && (second == ._colon || second == ._pipe) + && third == ._slash + ) + if isAbsolute { + // Standardize to "/[drive-letter]:/..." + if second == ._pipe { + var filePathArray = Array(utf8) + filePathArray[1] = ._colon + filePathArray.insert(._slash, at: 0) + filePath = String(decoding: filePathArray, as: UTF8.self) + } else { + filePath = "/" + filePath } } - #else - let isAbsolute = filePath.utf8.first == UInt8(ascii: "/") || filePath.utf8.first == UInt8(ascii: "~") - #endif return isAbsolute + #else // os(Windows) + #if !NO_FILESYSTEM + // Expand the tilde if present + if filePath.utf8.first == UInt8(ascii: "~") { + filePath = filePath.expandingTildeInPath + } + #endif + // Make sure the expanded path is absolute + return filePath.utf8.first == ._slash + #endif // os(Windows) } /// Initializes a newly created file URL referencing the local file or directory at path, relative to a base URL. diff --git a/Tests/FoundationEssentialsTests/URLTests.swift b/Tests/FoundationEssentialsTests/URLTests.swift index 360afbea9..9f9d43cb0 100644 --- a/Tests/FoundationEssentialsTests/URLTests.swift +++ b/Tests/FoundationEssentialsTests/URLTests.swift @@ -627,6 +627,26 @@ final class URLTests : XCTestCase { XCTAssertEqual(url.host, "*.xn--poema-9qae5a.com.br") } + func testURLTildeFilePath() throws { + var url = URL(filePath: "~") + // "~" must either be expanded to an absolute path or resolved against a base URL + XCTAssertTrue( + url.relativePath.utf8.first == ._slash || (url.baseURL != nil && url.path().utf8.first == ._slash) + ) + + url = URL(filePath: "~", directoryHint: .isDirectory) + XCTAssertTrue( + url.relativePath.utf8.first == ._slash || (url.baseURL != nil && url.path().utf8.first == ._slash) + ) + XCTAssertEqual(url.path().utf8.last, ._slash) + + url = URL(filePath: "~/") + XCTAssertTrue( + url.relativePath.utf8.first == ._slash || (url.baseURL != nil && url.path().utf8.first == ._slash) + ) + XCTAssertEqual(url.path().utf8.last, ._slash) + } + func testURLComponentsPercentEncodedUnencodedProperties() throws { var comp = URLComponents() From a02bd729e2e595ff6cae168f76d1818358108034 Mon Sep 17 00:00:00 2001 From: Jonathan Flat <50605158+jrflat@users.noreply.github.com> Date: Mon, 7 Oct 2024 12:01:37 -0600 Subject: [PATCH 4/5] (137068266) URL.fileSystemPath should strip leading slash for Windows drive letters (#964) --- Sources/FoundationEssentials/URL/URL.swift | 25 ++++++++++++++++++- .../FoundationEssentialsTests/URLTests.swift | 12 +++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/Sources/FoundationEssentials/URL/URL.swift b/Sources/FoundationEssentials/URL/URL.swift index 20e2e43a0..ba2f02c79 100644 --- a/Sources/FoundationEssentials/URL/URL.swift +++ b/Sources/FoundationEssentials/URL/URL.swift @@ -1319,9 +1319,32 @@ public struct URL: Equatable, Sendable, Hashable { } } + private static func windowsPath(for posixPath: String) -> String { + let utf8 = posixPath.utf8 + guard utf8.count >= 4 else { + return posixPath + } + // "C:\" is standardized to "/C:/" on initialization + let array = Array(utf8) + if array[0] == ._slash, + array[1].isAlpha, + array[2] == ._colon, + array[3] == ._slash { + return String(Substring(utf8.dropFirst())) + } + return posixPath + } + private static func fileSystemPath(for urlPath: String) -> String { let charsToLeaveEncoded: Set = [._slash, 0] - return Parser.percentDecode(urlPath._droppingTrailingSlashes, excluding: charsToLeaveEncoded) ?? "" + guard let posixPath = Parser.percentDecode(urlPath._droppingTrailingSlashes, excluding: charsToLeaveEncoded) else { + return "" + } + #if os(Windows) + return windowsPath(for: posixPath) + #else + return posixPath + #endif } var fileSystemPath: String { diff --git a/Tests/FoundationEssentialsTests/URLTests.swift b/Tests/FoundationEssentialsTests/URLTests.swift index 9f9d43cb0..3dc87fd58 100644 --- a/Tests/FoundationEssentialsTests/URLTests.swift +++ b/Tests/FoundationEssentialsTests/URLTests.swift @@ -330,6 +330,18 @@ final class URLTests : XCTestCase { try FileManager.default.removeItem(at: URL(filePath: "\(tempDirectory.path)/tmp-dir")) } + #if os(Windows) + func testURLWindowsDriveLetterPath() throws { + let url = URL(filePath: "C:\\test\\path", directoryHint: .notDirectory) + // .absoluteString and .path() use the RFC 8089 URL path + XCTAssertEqual(url.absoluteString, "file:///C:/test/path") + XCTAssertEqual(url.path(), "/C:/test/path") + // .path and .fileSystemPath strip the leading slash + XCTAssertEqual(url.path, "C:/test/path") + XCTAssertEqual(url.fileSystemPath, "C:/test/path") + } + #endif + func testURLFilePathRelativeToBase() throws { try FileManagerPlayground { Directory("dir") { From dabc6e4627c72065ef5cce0cd06958df4808d5e2 Mon Sep 17 00:00:00 2001 From: Jonathan Flat <50605158+jrflat@users.noreply.github.com> Date: Mon, 7 Oct 2024 11:49:51 -0600 Subject: [PATCH 5/5] (137287143) URL path extension APIs should strip trailing slashes (#965) --- .../String/String+Path.swift | 17 ++++++++++++-- .../StringTests.swift | 22 +++++++++++++++++++ .../FoundationEssentialsTests/URLTests.swift | 21 ++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/Sources/FoundationEssentials/String/String+Path.swift b/Sources/FoundationEssentials/String/String+Path.swift index 06d0bd29f..3657e6fd4 100644 --- a/Sources/FoundationEssentials/String/String+Path.swift +++ b/Sources/FoundationEssentials/String/String+Path.swift @@ -194,7 +194,11 @@ extension String { guard let lastDot = self.utf8.lastIndex(of: dot) else { return self } - return String(self[.. Bool { @@ -214,7 +218,16 @@ extension String { guard validatePathExtension(pathExtension) else { return self } - return self + ".\(pathExtension)" + var result = self._droppingTrailingSlashes + guard result != "/" else { + // Path was all slashes + return self + ".\(pathExtension)" + } + result += ".\(pathExtension)" + if utf8.last == ._slash { + result += "/" + } + return result } internal var pathExtension: String { diff --git a/Tests/FoundationEssentialsTests/StringTests.swift b/Tests/FoundationEssentialsTests/StringTests.swift index b5fff9aee..c7d650638 100644 --- a/Tests/FoundationEssentialsTests/StringTests.swift +++ b/Tests/FoundationEssentialsTests/StringTests.swift @@ -812,6 +812,19 @@ final class StringTests : XCTestCase { } } + func testAppendingPathExtension() { + XCTAssertEqual("".appendingPathExtension("foo"), ".foo") + XCTAssertEqual("/".appendingPathExtension("foo"), "/.foo") + XCTAssertEqual("//".appendingPathExtension("foo"), "//.foo") + XCTAssertEqual("/path".appendingPathExtension("foo"), "/path.foo") + XCTAssertEqual("/path.zip".appendingPathExtension("foo"), "/path.zip.foo") + XCTAssertEqual("/path/".appendingPathExtension("foo"), "/path.foo/") + XCTAssertEqual("/path//".appendingPathExtension("foo"), "/path.foo/") + XCTAssertEqual("path".appendingPathExtension("foo"), "path.foo") + XCTAssertEqual("path/".appendingPathExtension("foo"), "path.foo/") + XCTAssertEqual("path//".appendingPathExtension("foo"), "path.foo/") + } + func testDeletingPathExtenstion() { XCTAssertEqual("".deletingPathExtension(), "") XCTAssertEqual("/".deletingPathExtension(), "/") @@ -834,6 +847,15 @@ final class StringTests : XCTestCase { XCTAssertEqual("/foo.bar/bar.baz/baz.zip".deletingPathExtension(), "/foo.bar/bar.baz/baz") XCTAssertEqual("/.././.././a.zip".deletingPathExtension(), "/.././.././a") XCTAssertEqual("/.././.././.".deletingPathExtension(), "/.././.././.") + + XCTAssertEqual("path.foo".deletingPathExtension(), "path") + XCTAssertEqual("path.foo.zip".deletingPathExtension(), "path.foo") + XCTAssertEqual("/path.foo".deletingPathExtension(), "/path") + XCTAssertEqual("/path.foo.zip".deletingPathExtension(), "/path.foo") + XCTAssertEqual("path.foo/".deletingPathExtension(), "path/") + XCTAssertEqual("path.foo//".deletingPathExtension(), "path/") + XCTAssertEqual("/path.foo/".deletingPathExtension(), "/path/") + XCTAssertEqual("/path.foo//".deletingPathExtension(), "/path/") } func test_dataUsingEncoding() { diff --git a/Tests/FoundationEssentialsTests/URLTests.swift b/Tests/FoundationEssentialsTests/URLTests.swift index 3dc87fd58..d00ef806d 100644 --- a/Tests/FoundationEssentialsTests/URLTests.swift +++ b/Tests/FoundationEssentialsTests/URLTests.swift @@ -659,6 +659,27 @@ final class URLTests : XCTestCase { XCTAssertEqual(url.path().utf8.last, ._slash) } + func testURLPathExtensions() throws { + var url = URL(filePath: "/path", directoryHint: .notDirectory) + url.appendPathExtension("foo") + XCTAssertEqual(url.path(), "/path.foo") + url.deletePathExtension() + XCTAssertEqual(url.path(), "/path") + + url = URL(filePath: "/path", directoryHint: .isDirectory) + url.appendPathExtension("foo") + XCTAssertEqual(url.path(), "/path.foo/") + url.deletePathExtension() + XCTAssertEqual(url.path(), "/path/") + + url = URL(filePath: "/path/", directoryHint: .inferFromPath) + url.appendPathExtension("foo") + XCTAssertEqual(url.path(), "/path.foo/") + url.append(path: "/////") + url.deletePathExtension() + XCTAssertEqual(url.path(), "/path/") + } + func testURLComponentsPercentEncodedUnencodedProperties() throws { var comp = URLComponents()