Skip to content

[6.0.x] URL path bug fixes #969

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
Show file tree
Hide file tree
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
Expand Up @@ -194,7 +194,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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -366,7 +379,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 {
Expand Down Expand Up @@ -529,8 +542,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
Expand Down Expand Up @@ -569,7 +584,7 @@ extension String {
}
}

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


#if !NO_FILESYSTEM

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

private var _standardizingPath: String {
Expand Down Expand Up @@ -616,7 +633,8 @@ extension String {
var standardizingPath: String {
expandingTildeInPath._standardizingPath
}
#endif // !NO_FILESYSTEM

#endif // !NO_FILESYSTEM

// _NSPathComponents
var pathComponents: [String] {
Expand Down
145 changes: 83 additions & 62 deletions Sources/FoundationEssentials/URL/URL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand All @@ -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()
Expand Down Expand Up @@ -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
}
Expand Down
22 changes: 22 additions & 0 deletions Tests/FoundationEssentialsTests/StringTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(), "/")
Expand All @@ -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() {
Expand Down
Loading