Skip to content

Commit 66ea364

Browse files
committed
(159787949) Use file system representation for file paths in URL
1 parent cd6ebe2 commit 66ea364

File tree

2 files changed

+204
-2
lines changed

2 files changed

+204
-2
lines changed

Sources/FoundationEssentials/URL/URL_Swift.swift

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable {
4141
internal let _parseInfo: URLParseInfo
4242
internal let _baseURL: URL?
4343
internal let _encoding: String.Encoding
44+
// URL was created from a file path initializer and is absolute
45+
private let _isCanonicalFileURL: Bool
4446

4547
#if FOUNDATION_FRAMEWORK
4648
// Used frequently for NS/CFURL behaviors
@@ -90,6 +92,7 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable {
9092
_parseInfo = parseInfo
9193
_baseURL = (forceBaseURL || parseInfo.scheme == nil) ? base?.absoluteURL : nil
9294
_encoding = encoding
95+
_isCanonicalFileURL = false
9396
}
9497

9598
convenience init?(string: String) {
@@ -132,10 +135,13 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable {
132135
}
133136

134137
convenience init(filePath path: String, directoryHint: URL.DirectoryHint = .inferFromPath, relativeTo base: URL? = nil) {
135-
self.init(filePath: path, pathStyle: URL.defaultPathStyle, directoryHint: directoryHint, relativeTo: base)
138+
// .init(fileURLWithPath:) inits call through here, convert path to FSR now
139+
self.init(filePath: path.fileSystemRepresentation, pathStyle: URL.defaultPathStyle, directoryHint: directoryHint, relativeTo: base)
136140
}
137141

138142
internal init(filePath path: String, pathStyle: URL.PathStyle, directoryHint: URL.DirectoryHint = .inferFromPath, relativeTo base: URL? = nil) {
143+
// Note: don't convert to file system representation in this init since
144+
// .init(fileURLWithFileSystemRepresentation:) calls into it, too.
139145
var baseURL = base
140146
guard !path.isEmpty else {
141147
#if !NO_FILESYSTEM
@@ -144,6 +150,7 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable {
144150
_parseInfo = Parser.parse(filePath: "./", isAbsolute: false)
145151
_baseURL = baseURL?.absoluteURL
146152
_encoding = .utf8
153+
_isCanonicalFileURL = false
147154
return
148155
}
149156

@@ -176,6 +183,7 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable {
176183
_parseInfo = parseInfo
177184
_baseURL = nil // Drop the base URL since we have an HTTP scheme
178185
_encoding = .utf8
186+
_isCanonicalFileURL = false
179187
return
180188
}
181189
}
@@ -220,10 +228,12 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable {
220228
let encodedPath = Parser.percentEncode(filePath, component: .path) ?? "/"
221229
_parseInfo = Parser.parse(filePath: encodedPath, isAbsolute: true)
222230
_baseURL = nil // Drop the baseURL if the URL is absolute
231+
_isCanonicalFileURL = true
223232
} else {
224233
let encodedPath = Parser.percentEncode(filePath, component: .path) ?? ""
225234
_parseInfo = Parser.parse(filePath: encodedPath, isAbsolute: false)
226235
_baseURL = baseURL?.absoluteURL
236+
_isCanonicalFileURL = false
227237
}
228238
_encoding = .utf8
229239
}
@@ -232,6 +242,7 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable {
232242
_parseInfo = url._parseInfo
233243
_baseURL = url._baseURL?.absoluteURL
234244
_encoding = url._encoding
245+
_isCanonicalFileURL = url._isCanonicalFileURL
235246
}
236247

237248
convenience init?(dataRepresentation: Data, relativeTo base: URL?, isAbsolute: Bool) {
@@ -256,7 +267,8 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable {
256267
convenience init(fileURLWithFileSystemRepresentation path: UnsafePointer<Int8>, isDirectory: Bool, relativeTo base: URL?) {
257268
let pathString = String(cString: path)
258269
let directoryHint: URL.DirectoryHint = isDirectory ? .isDirectory : .notDirectory
259-
self.init(filePath: pathString, directoryHint: directoryHint, relativeTo: base)
270+
// Call the internal init so we don't automatically convert path to its decomposed form
271+
self.init(filePath: pathString, pathStyle: URL.defaultPathStyle, directoryHint: directoryHint, relativeTo: base)
260272
}
261273

262274
internal var encodedComponents: URLParseInfo.EncodedComponentSet {
@@ -389,6 +401,7 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable {
389401

390402
private static let fileSchemeUTF8 = Array("file".utf8)
391403
var isFileURL: Bool {
404+
if _isCanonicalFileURL { return true }
392405
guard let scheme else { return false }
393406
return scheme.lowercased().utf8.elementsEqual(Self.fileSchemeUTF8)
394407
}
@@ -624,6 +637,9 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable {
624637
}
625638

626639
func withUnsafeFileSystemRepresentation<ResultType>(_ block: (UnsafePointer<Int8>?) throws -> ResultType) rethrows -> ResultType {
640+
if _isCanonicalFileURL {
641+
return try fileSystemPath().withCString { try block($0) }
642+
}
627643
return try fileSystemPath().withFileSystemRepresentation { try block($0) }
628644
}
629645

@@ -693,6 +709,13 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable {
693709
var pathToAppend = String(path)
694710
#endif
695711

712+
#if FOUNDATION_FRAMEWORK || canImport(Darwin)
713+
if isFileURL {
714+
// Use the file system (decomposed) representation
715+
pathToAppend = pathToAppend.fileSystemRepresentation
716+
}
717+
#endif
718+
696719
if !encodingSlashes && !compatibility {
697720
pathToAppend = Parser.percentEncode(pathComponent: pathToAppend)
698721
} else {
@@ -880,6 +903,13 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable {
880903
guard !pathExtension.isEmpty, !_parseInfo.path.isEmpty else {
881904
return nil
882905
}
906+
#if FOUNDATION_FRAMEWORK || canImport(Darwin)
907+
var pathExtension = pathExtension
908+
if isFileURL {
909+
// Use the file system (decomposed) representation
910+
pathExtension = pathExtension.fileSystemRepresentation
911+
}
912+
#endif
883913
var components = URLComponents(parseInfo: _parseInfo)
884914
// pathExtension might need to be percent-encoded
885915
let encodedExtension = if compatibility {
@@ -1101,6 +1131,21 @@ internal final class _SwiftURL: Sendable, Hashable, Equatable {
11011131

11021132
}
11031133

1134+
private extension String {
1135+
var fileSystemRepresentation: String {
1136+
#if FOUNDATION_FRAMEWORK || canImport(Darwin)
1137+
return withFileSystemRepresentation { fsRep in
1138+
guard let fsRep else {
1139+
return self
1140+
}
1141+
return String(cString: fsRep)
1142+
}
1143+
#else
1144+
return self
1145+
#endif
1146+
}
1147+
}
1148+
11041149
#if FOUNDATION_FRAMEWORK
11051150
internal import CoreFoundation_Private.CFURL
11061151

Tests/FoundationEssentialsTests/URLTests.swift

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,163 @@ private struct URLTests {
423423
try FileManager.default.removeItem(at: URL(filePath: "\(tempDirectory.path)/tmp-dir"))
424424
}
425425

426+
#if FOUNDATION_FRAMEWORK || canImport(Darwin)
427+
@Test func fileSystemRepresentations() throws {
428+
let base = "/base/"
429+
let pathNFC = "/caf\u{E9}"
430+
let relativeNFC = "caf\u{E9}"
431+
let pathNFD = "/cafe\u{301}"
432+
let relativeNFD = "cafe\u{301}"
433+
434+
let resolvedPathNFC = "/base/caf\u{E9}"
435+
let resolvedPathNFD = "/base/cafe\u{301}"
436+
let baseExtensionNFD = "/base.cafe\u{301}"
437+
let doubleCafeNFD = "/cafe\u{301}/cafe\u{301}"
438+
439+
// URL(filePath:) should always convert the input to decomposed (NFD) representation
440+
let baseURL = URL(filePath: base)
441+
let urlNFC = URL(filePath: pathNFC)
442+
let urlRelativeNFC = URL(filePath: relativeNFC, relativeTo: baseURL)
443+
let urlNFD = URL(filePath: pathNFD)
444+
let urlRelativeNFD = URL(filePath: relativeNFD, relativeTo: baseURL)
445+
446+
func equalBytes(_ p1: UnsafePointer<CChar>, _ p2: UnsafePointer<CChar>) -> Bool {
447+
return strcmp(p1, p2) == 0
448+
}
449+
450+
// Compare bytes to ensure we have the right representation
451+
#expect(equalBytes(urlNFC.path, pathNFD))
452+
#expect(equalBytes(urlNFD.path, pathNFD))
453+
#expect(urlNFC == urlNFD)
454+
455+
#expect(equalBytes(urlRelativeNFC.path, resolvedPathNFD))
456+
#expect(equalBytes(urlRelativeNFD.path, resolvedPathNFD))
457+
#expect(urlRelativeNFC == urlRelativeNFD)
458+
459+
// withUnsafeFileSystemRepresentation should return a pointer to decomposed bytes
460+
try urlNFC.withUnsafeFileSystemRepresentation { fsRep in
461+
let fsRep = try #require(fsRep)
462+
#expect(equalBytes(fsRep, pathNFD))
463+
}
464+
465+
try urlNFD.withUnsafeFileSystemRepresentation { fsRep in
466+
let fsRep = try #require(fsRep)
467+
#expect(equalBytes(fsRep, pathNFD))
468+
}
469+
470+
try urlRelativeNFC.withUnsafeFileSystemRepresentation { fsRep in
471+
let fsRep = try #require(fsRep)
472+
#expect(equalBytes(fsRep, resolvedPathNFD))
473+
}
474+
475+
try urlRelativeNFD.withUnsafeFileSystemRepresentation { fsRep in
476+
let fsRep = try #require(fsRep)
477+
#expect(equalBytes(fsRep, resolvedPathNFD))
478+
}
479+
480+
// ...unless we specifically .init(fileURLWithFileSystemRepresentation:) with absolute NFC
481+
let urlNFCFSR = URL(fileURLWithFileSystemRepresentation: pathNFC, isDirectory: false, relativeTo: nil)
482+
let urlNFDFSR = URL(fileURLWithFileSystemRepresentation: pathNFD, isDirectory: false, relativeTo: nil)
483+
484+
#expect(equalBytes(urlNFCFSR.path, pathNFC))
485+
#expect(equalBytes(urlNFDFSR.path, pathNFD))
486+
#expect(urlNFCFSR != urlNFDFSR)
487+
488+
try urlNFCFSR.withUnsafeFileSystemRepresentation { fsRep in
489+
let fsRep = try #require(fsRep)
490+
#expect(equalBytes(fsRep, pathNFC))
491+
}
492+
493+
try urlNFDFSR.withUnsafeFileSystemRepresentation { fsRep in
494+
let fsRep = try #require(fsRep)
495+
#expect(equalBytes(fsRep, pathNFD))
496+
}
497+
498+
// If we .init(fileURLWithFileSystemRepresentation:) with a relative path,
499+
// we store the given representation but must convert when returning it
500+
let urlRelativeNFCFSR = URL(fileURLWithFileSystemRepresentation: relativeNFC, isDirectory: false, relativeTo: baseURL)
501+
let urlRelativeNFDFSR = URL(fileURLWithFileSystemRepresentation: relativeNFD, isDirectory: false, relativeTo: baseURL)
502+
503+
#expect(equalBytes(urlRelativeNFCFSR.path, resolvedPathNFC))
504+
#expect(equalBytes(urlRelativeNFDFSR.path, resolvedPathNFD))
505+
#expect(urlRelativeNFCFSR != urlRelativeNFDFSR)
506+
507+
try urlRelativeNFCFSR.withUnsafeFileSystemRepresentation { fsRep in
508+
let fsRep = try #require(fsRep)
509+
#expect(equalBytes(fsRep, resolvedPathNFD))
510+
}
511+
512+
try urlRelativeNFDFSR.withUnsafeFileSystemRepresentation { fsRep in
513+
let fsRep = try #require(fsRep)
514+
#expect(equalBytes(fsRep, resolvedPathNFD))
515+
}
516+
517+
// Appending a path component should convert to decomposed for file URLs
518+
let baseWithNFCComponent = baseURL.appending(path: relativeNFC)
519+
#expect(equalBytes(baseWithNFCComponent.path, resolvedPathNFD))
520+
521+
let baseWithNFDComponent = baseURL.appending(path: relativeNFD)
522+
#expect(equalBytes(baseWithNFDComponent.path, resolvedPathNFD))
523+
#expect(baseWithNFCComponent == baseWithNFDComponent)
524+
525+
let urlNFCWithNFCComponent = urlNFC.appending(path: relativeNFC)
526+
let urlNFCWithNFDComponent = urlNFC.appending(path: relativeNFD)
527+
let urlNFDWithNFCComponent = urlNFD.appending(path: relativeNFC)
528+
let urlNFDWithNFDComponent = urlNFD.appending(path: relativeNFD)
529+
#expect(equalBytes(urlNFCWithNFCComponent.path, doubleCafeNFD))
530+
#expect(equalBytes(urlNFCWithNFDComponent.path, doubleCafeNFD))
531+
#expect(equalBytes(urlNFDWithNFCComponent.path, doubleCafeNFD))
532+
#expect(equalBytes(urlNFDWithNFDComponent.path, doubleCafeNFD))
533+
#expect(urlNFCWithNFCComponent == urlNFCWithNFDComponent)
534+
#expect(urlNFCWithNFCComponent == urlNFDWithNFCComponent)
535+
#expect(urlNFCWithNFCComponent == urlNFDWithNFDComponent)
536+
537+
// Appending an extension should convert to decomposed for file URLs
538+
let baseWithNFCExtension = baseURL.appendingPathExtension(relativeNFC)
539+
#expect(equalBytes(baseWithNFCExtension.path, baseExtensionNFD))
540+
541+
let baseWithNFDExtension = baseURL.appendingPathExtension(relativeNFD)
542+
#expect(equalBytes(baseWithNFDExtension.path, baseExtensionNFD))
543+
#expect(baseWithNFCExtension == baseWithNFDExtension)
544+
545+
// None of these conversions apply for initializing or appending to non-file URLs
546+
let httpBase = try #require(URL(string: "https://example.com/"))
547+
let httpRelativeNFC = try #require(URL(string: relativeNFC, relativeTo: httpBase))
548+
let httpRelativeNFD = try #require(URL(string: relativeNFD, relativeTo: httpBase))
549+
let httpWithNFCComponent = httpBase.appending(path: relativeNFC)
550+
let httpWithNFDComponent = httpBase.appending(path: relativeNFD)
551+
552+
#expect(equalBytes(httpRelativeNFC.path, pathNFC))
553+
#expect(equalBytes(httpRelativeNFD.path, pathNFD))
554+
#expect(httpRelativeNFC != httpRelativeNFD)
555+
556+
#expect(equalBytes(httpWithNFCComponent.path, pathNFC))
557+
#expect(equalBytes(httpWithNFDComponent.path, pathNFD))
558+
#expect(httpWithNFCComponent != httpWithNFDComponent)
559+
560+
// Except when we explicitly get the file system representation
561+
try httpRelativeNFC.withUnsafeFileSystemRepresentation { fsRep in
562+
let fsRep = try #require(fsRep)
563+
#expect(equalBytes(fsRep, pathNFD))
564+
}
565+
566+
try httpRelativeNFD.withUnsafeFileSystemRepresentation { fsRep in
567+
let fsRep = try #require(fsRep)
568+
#expect(equalBytes(fsRep, pathNFD))
569+
}
570+
571+
try httpWithNFCComponent.withUnsafeFileSystemRepresentation { fsRep in
572+
let fsRep = try #require(fsRep)
573+
#expect(equalBytes(fsRep, pathNFD))
574+
}
575+
576+
try httpWithNFDComponent.withUnsafeFileSystemRepresentation { fsRep in
577+
let fsRep = try #require(fsRep)
578+
#expect(equalBytes(fsRep, pathNFD))
579+
}
580+
}
581+
#endif
582+
426583
#if os(Windows)
427584
@Test func windowsDriveLetterPath() throws {
428585
var url = URL(filePath: #"C:\test\path"#, directoryHint: .notDirectory)

0 commit comments

Comments
 (0)