Skip to content

Commit 50e4822

Browse files
authored
Merge pull request #167 from swhitty/backport-standardized-url
*OS 26 Compatibility aka Backport standardized URL from swift-foundation
2 parents 6f06029 + 19971ed commit 50e4822

File tree

4 files changed

+196
-14
lines changed

4 files changed

+196
-14
lines changed
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
//
2+
// HTTPDecoder+StandardizePath.swift
3+
// FlyingFox
4+
//
5+
// Created by Simon Whitty on 23/08/2025.
6+
// Copyright © 2025 Simon Whitty. All rights reserved.
7+
//
8+
// Distributed under the permissive MIT license
9+
// Get the latest version from here:
10+
//
11+
// https://github.com/swhitty/FlyingFox
12+
//
13+
// Permission is hereby granted, free of charge, to any person obtaining a copy
14+
// of this software and associated documentation files (the "Software"), to deal
15+
// in the Software without restriction, including without limitation the rights
16+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17+
// copies of the Software, and to permit persons to whom the Software is
18+
// furnished to do so, subject to the following conditions:
19+
//
20+
// The above copyright notice and this permission notice shall be included in all
21+
// copies or substantial portions of the Software.
22+
//
23+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29+
// SOFTWARE.
30+
//
31+
32+
import FlyingSocks
33+
import Foundation
34+
35+
extension HTTPDecoder {
36+
37+
static func standardizePath(_ path: String) -> String? {
38+
#if canImport(Darwin)
39+
#if compiler(>=6.2)
40+
if #available(macOS 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) {
41+
return URL(string: path)?.standardized.path
42+
} else {
43+
return standardizePathDarwinFallback(path)
44+
}
45+
#else
46+
return standardizePathDarwinFallback(path)
47+
#endif
48+
#else
49+
return URL(string: path)?.standardized.path
50+
#endif
51+
}
52+
53+
private static func standardizePathDarwinFallback(_ path: String) -> String? {
54+
if #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, visionOS 26.0, *) {
55+
return URL(string: path.removingDotSegments)?.standardized.path
56+
} else {
57+
return URL(string: path)?.standardized.path
58+
}
59+
}
60+
}
61+
62+
// Fix taken from
63+
// https://github.com/swiftlang/swift-foundation/blob/main/Sources/FoundationEssentials/URL/URL_Swift.swift
64+
65+
//===----------------------------------------------------------------------===//
66+
//
67+
// This source file is part of the Swift.org open source project
68+
//
69+
// Copyright (c) 2025 Apple Inc. and the Swift project authors
70+
// Licensed under Apache License v2.0 with Runtime Library Exception
71+
//
72+
// See https://swift.org/LICENSE.txt for license information
73+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
74+
//
75+
//===----------------------------------------------------------------------===//
76+
77+
private extension String {
78+
@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, visionOS 26.0, *)
79+
var removingDotSegments: String {
80+
guard !isEmpty else {
81+
return ""
82+
}
83+
84+
enum RemovingDotState {
85+
case initial
86+
case dot
87+
case dotDot
88+
case slash
89+
case slashDot
90+
case slashDotDot
91+
case appendUntilSlash
92+
}
93+
94+
return String(unsafeUninitializedCapacity: utf8.count) { buffer in
95+
96+
// State machine for remove_dot_segments() from RFC 3986
97+
//
98+
// First, remove all "./" and "../" prefixes by moving through
99+
// the .initial, .dot, and .dotDot states (without appending).
100+
//
101+
// Then, move through the remaining states/components, first
102+
// checking if the component is special ("/./" or "/../") so
103+
// that we only append when necessary.
104+
105+
var state = RemovingDotState.initial
106+
var i = 0
107+
for v in utf8 {
108+
switch state {
109+
case .initial:
110+
if v == ._dot {
111+
state = .dot
112+
} else if v == ._slash {
113+
state = .slash
114+
} else {
115+
buffer[i] = v
116+
i += 1
117+
state = .appendUntilSlash
118+
}
119+
case .dot:
120+
if v == ._dot {
121+
state = .dotDot
122+
} else if v == ._slash {
123+
state = .initial
124+
} else {
125+
i = buffer[i...i+1].initialize(fromContentsOf: [._dot, v])
126+
state = .appendUntilSlash
127+
}
128+
case .dotDot:
129+
if v == ._slash {
130+
state = .initial
131+
} else {
132+
i = buffer[i...i+2].initialize(fromContentsOf: [._dot, ._dot, v])
133+
state = .appendUntilSlash
134+
}
135+
case .slash:
136+
if v == ._dot {
137+
state = .slashDot
138+
} else if v == ._slash {
139+
buffer[i] = ._slash
140+
i += 1
141+
} else {
142+
i = buffer[i...i+1].initialize(fromContentsOf: [._slash, v])
143+
state = .appendUntilSlash
144+
}
145+
case .slashDot:
146+
if v == ._dot {
147+
state = .slashDotDot
148+
} else if v == ._slash {
149+
state = .slash
150+
} else {
151+
i = buffer[i...i+2].initialize(fromContentsOf: [._slash, ._dot, v])
152+
state = .appendUntilSlash
153+
}
154+
case .slashDotDot:
155+
if v == ._slash {
156+
// Cheaply remove the previous component by moving i to its start
157+
i = buffer[..<i].lastIndex(of: ._slash) ?? 0
158+
state = .slash
159+
} else {
160+
i = buffer[i...i+3].initialize(fromContentsOf: [._slash, ._dot, ._dot, v])
161+
state = .appendUntilSlash
162+
}
163+
case .appendUntilSlash:
164+
if v == ._slash {
165+
state = .slash
166+
} else {
167+
buffer[i] = v
168+
i += 1
169+
}
170+
}
171+
}
172+
173+
switch state {
174+
case .slash: fallthrough
175+
case .slashDot:
176+
buffer[i] = ._slash
177+
i += 1
178+
case .slashDotDot:
179+
// Note: "/.." is not yet appended to the buffer
180+
i = buffer[..<i].lastIndex(of: ._slash) ?? 0
181+
buffer[i] = ._slash
182+
i += 1
183+
default:
184+
break
185+
}
186+
187+
return i
188+
}
189+
}
190+
}
191+
192+
private extension UInt8 {
193+
static var _slash: UInt8 { UInt8(ascii: "/") }
194+
static var _dot: UInt8 { UInt8(ascii: ".") }
195+
}

FlyingFox/Sources/HTTPDecoder.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ struct HTTPDecoder {
9191
}
9292

9393
func makeComponents(from comps: URLComponents?) -> (path: String, query: [HTTPRequest.QueryItem]) {
94-
let path = (comps?.percentEncodedPath).flatMap { URL(string: $0)?.standardized.path } ?? ""
94+
let path = (comps?.percentEncodedPath).flatMap(HTTPDecoder.standardizePath) ?? ""
9595
let query = comps?.queryItems?.map {
9696
HTTPRequest.QueryItem(name: $0.name, value: $0.value ?? "")
9797
}

FlyingFox/Tests/HTTPDecoderTests.swift

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,15 +108,9 @@ struct HTTPDecoderTests {
108108
"""
109109
)
110110

111-
#if canImport(Darwin)
112-
#expect(
113-
request.path == "a/c/d.html"
114-
)
115-
#else
116111
#expect(
117112
request.path == "/a/c/d.html"
118113
)
119-
#endif
120114

121115
#expect(
122116
request.query == [

FlyingFox/XCTests/HTTPDecoderTests.swift

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,17 +106,10 @@ final class HTTPDecoderTests: XCTestCase {
106106
"""
107107
)
108108

109-
#if canImport(Darwin)
110-
XCTAssertEqual(
111-
request.path,
112-
"a/c/d.html"
113-
)
114-
#else
115109
XCTAssertEqual(
116110
request.path,
117111
"/a/c/d.html"
118112
)
119-
#endif
120113

121114
XCTAssertEqual(
122115
request.query,

0 commit comments

Comments
 (0)