Skip to content

Commit 3cc1cbc

Browse files
authored
Merge pull request #1636 from ahoppen/more-percent-encoding
Add an extra percent encoding layer when encoding DocumentURIs to LSP requests
2 parents 3b16288 + b2c17c7 commit 3cc1cbc

File tree

3 files changed

+72
-5
lines changed

3 files changed

+72
-5
lines changed

Sources/LanguageServerProtocol/SupportTypes/DocumentURI.swift

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,19 @@ public struct DocumentURI: Codable, Hashable, Sendable {
8484
}
8585

8686
public init(from decoder: Decoder) throws {
87-
try self.init(string: decoder.singleValueContainer().decode(String.self))
87+
let string = try decoder.singleValueContainer().decode(String.self)
88+
guard let url = URL(string: string) else {
89+
throw FailedToConstructDocumentURIFromStringError(string: string)
90+
}
91+
if url.query() != nil, var urlComponents = URLComponents(string: url.absoluteString) {
92+
// See comment in `encode(to:)`
93+
urlComponents.percentEncodedQuery = urlComponents.percentEncodedQuery!.removingPercentEncoding
94+
if let rewrittenQuery = urlComponents.url {
95+
self.init(rewrittenQuery)
96+
return
97+
}
98+
}
99+
self.init(url)
88100
}
89101

90102
/// Equality check to handle escape sequences in file URLs.
@@ -97,7 +109,36 @@ public struct DocumentURI: Codable, Hashable, Sendable {
97109
hasher.combine(self.pseudoPath)
98110
}
99111

112+
private static let additionalQueryEncodingCharacterSet = CharacterSet(charactersIn: "?=&%").inverted
113+
100114
public func encode(to encoder: Encoder) throws {
101-
try storage.absoluteString.encode(to: encoder)
115+
let urlToEncode: URL
116+
if let query = storage.query(percentEncoded: true), var components = URLComponents(string: storage.absoluteString) {
117+
// The URI standard RFC 3986 is ambiguous about whether percent encoding and their represented characters are
118+
// considered equivalent. VS Code considers them equivalent and treats them the same:
119+
//
120+
// vscode.Uri.parse("x://a?b=xxxx%3Dyyyy").toString() -> 'x://a?b%3Dxxxx%3Dyyyy'
121+
// vscode.Uri.parse("x://a?b=xxxx%3Dyyyy").toString(/*skipEncoding=*/true) -> 'x://a?b=xxxx=yyyy'
122+
//
123+
// This causes issues because SourceKit-LSP's macro expansion URLs encoded by URLComponents use `=` to denote the
124+
// separation of a key and a value in the outer query. The value of the `parent` key may itself contain query
125+
// items, which use the escaped form '%3D'. Simplified, such a URL may look like
126+
// scheme://host?parent=scheme://host?line%3D2
127+
// But after running this through VS Code's URI type `=` and `%3D` get canonicalized and are indistinguishable.
128+
// To avoid this ambiguity, always percent escape the characters we use to distinguish URL query parameters,
129+
// producing the following URL.
130+
// scheme://host?parent%3Dscheme://host%3Fline%253D2
131+
components.percentEncodedQuery =
132+
query
133+
.addingPercentEncoding(withAllowedCharacters: Self.additionalQueryEncodingCharacterSet)
134+
if let componentsUrl = components.url {
135+
urlToEncode = componentsUrl
136+
} else {
137+
urlToEncode = self.storage
138+
}
139+
} else {
140+
urlToEncode = self.storage
141+
}
142+
try urlToEncode.absoluteString.encode(to: encoder)
102143
}
103144
}

Sources/SKTestSupport/CheckCoding.swift

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,12 @@ package func checkCoding<T: Codable & Equatable>(
4343
XCTAssertEqual(json, str, file: file, line: line)
4444

4545
let decoder = JSONDecoder()
46-
let decodedValue = try! decoder.decode(WrapFragment<T>.self, from: data).value
47-
48-
XCTAssertEqual(value, decodedValue, file: file, line: line)
46+
XCTAssertNoThrow(
47+
try {
48+
let decodedValue = try decoder.decode(WrapFragment<T>.self, from: data).value
49+
XCTAssertEqual(value, decodedValue, file: file, line: line)
50+
}()
51+
)
4952
}
5053

5154
/// JSONEncoder requires the top-level value to be encoded as a JSON container (array or object). Give it one.

Tests/LanguageServerProtocolTests/CodingTests.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1343,6 +1343,29 @@ final class CodingTests: XCTestCase {
13431343
"""
13441344
)
13451345
}
1346+
1347+
func testDocumentUriQueryParameterCoding() throws {
1348+
checkCoding(
1349+
try DocumentURI(string: "scheme://host?parent=scheme://host?line%3D2"),
1350+
json: #"""
1351+
"scheme:\/\/host?parent%3Dscheme:\/\/host%3Fline%253D2"
1352+
"""#
1353+
)
1354+
1355+
checkCoding(
1356+
try DocumentURI(string: "scheme://host?parent=scheme://host?parent%3Dscheme://host%3Fkey%253Dvalue"),
1357+
json: #"""
1358+
"scheme:\/\/host?parent%3Dscheme:\/\/host%3Fparent%253Dscheme:\/\/host%253Fkey%25253Dvalue"
1359+
"""#
1360+
)
1361+
1362+
checkCoding(
1363+
try DocumentURI(string: "scheme://host?parent=with%23hash"),
1364+
json: #"""
1365+
"scheme:\/\/host?parent%3Dwith%2523hash"
1366+
"""#
1367+
)
1368+
}
13461369
}
13471370

13481371
func with<T>(_ value: T, mutate: (inout T) -> Void) -> T {

0 commit comments

Comments
 (0)