Skip to content

[6.0.2] URL path bug fixes #983

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Oct 14, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions Sources/FoundationEssentials/String/String+Path.swift
Original file line number Diff line number Diff line change
@@ -192,7 +192,11 @@ extension String {
guard let lastDot = self.utf8.lastIndex(of: dot) else {
return self
}
return String(self[..<lastDot])
var result = String(self[..<lastDot])
if utf8.last == ._slash {
result += "/"
}
return result
}

private func validatePathExtension(_ pathExtension: String) -> Bool {
@@ -212,7 +216,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 {
@@ -364,7 +377,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 {
@@ -525,8 +538,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
@@ -565,7 +580,7 @@ extension String {
}
}

private var _droppingTrailingSlashes: String {
internal var _droppingTrailingSlashes: String {
guard !self.isEmpty else {
return self
}
@@ -575,7 +590,9 @@ extension String {
}
return String(self[...lastNonSlash])
}


#if !NO_FILESYSTEM

static var NETWORK_PREFIX: String { #"\\"# }

private var _standardizingPath: String {
@@ -612,7 +629,8 @@ extension String {
var standardizingPath: String {
expandingTildeInPath._standardizingPath
}
#endif // !NO_FILESYSTEM

#endif // !NO_FILESYSTEM

// _NSPathComponents
var pathComponents: [String] {
145 changes: 83 additions & 62 deletions Sources/FoundationEssentials/URL/URL.swift
Original file line number Diff line number Diff line change
@@ -1319,13 +1319,32 @@ 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()
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<UInt8> = [._slash, 0]
return Parser.percentDecode(result, 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 {
@@ -2026,55 +2045,65 @@ 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 filePath.utf8.first == ._slash {
return true
}
#if os(Windows)
var isAbsolute = false
let utf8 = filePath.utf8
if utf8.first == ._backslash {
// 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 == ._backslash
)

if isAbsolute {
// Standardize to "\[drive-letter]:\..."
if second == ._pipe {
var filePathArray = Array(utf8)
filePathArray[1] = ._colon
filePathArray.insert(._backslash, 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.
@@ -2111,10 +2140,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

@@ -2126,41 +2154,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()
@@ -2438,6 +2456,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
}
22 changes: 22 additions & 0 deletions Tests/FoundationEssentialsTests/StringTests.swift
Original file line number Diff line number Diff line change
@@ -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() {
104 changes: 104 additions & 0 deletions Tests/FoundationEssentialsTests/URLTests.swift
Original file line number Diff line number Diff line change
@@ -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") {
@@ -571,11 +583,103 @@ 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 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")
}

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 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()