Skip to content

Commit b96f8e9

Browse files
committed
Add an extra percent encoding layer when encoding DocumentURIs to LSP requests
The URI standard RFC 3986 is ambiguous about whether percent encoding and their represented characters are considered equivalent. VS Code considers them equivalent and treats them the same: ```js vscode.Uri.parse("x://a?b=xxxx%3Dyyyy").toString() -> 'x://a?b%3Dxxxx%3Dyyyy' vscode.Uri.parse("x://a?b=xxxx%3Dyyyy").toString(/*skipEncoding=*/true) -> 'x://a?b=xxxx=yyyy' ``` This causes issues because SourceKit-LSP's macro expansion URLs encoded by URLComponents use `=` do denote the separation of a key and a value in the outer query. The value of the `parent` key may itself contain query items, which use the escaped form '%3D'. Simplified, such a URL may look like `scheme://host?parent=scheme://host?line%3D2`. But after running this through VS Code's URI type `=` and `%3D` get canonicalized and are indistinguishable. To avoid this ambiguity, always percent escape the characters we use to distinguish URL query parameters, producing the following URL: `scheme://host?parent%3Dscheme://host%3Fline%253D2`.
1 parent 5833322 commit b96f8e9

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 `=` do 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=with space"),
1357+
json: #"""
1358+
"scheme:\/\/host?parent%3Dwith%2520space"
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)