Skip to content

Add an extra percent encoding layer when encoding DocumentURIs to LSP requests #1636

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 1 commit into from
Aug 21, 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
45 changes: 43 additions & 2 deletions Sources/LanguageServerProtocol/SupportTypes/DocumentURI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,19 @@ public struct DocumentURI: Codable, Hashable, Sendable {
}

public init(from decoder: Decoder) throws {
try self.init(string: decoder.singleValueContainer().decode(String.self))
let string = try decoder.singleValueContainer().decode(String.self)
guard let url = URL(string: string) else {
throw FailedToConstructDocumentURIFromStringError(string: string)
}
if url.query() != nil, var urlComponents = URLComponents(string: url.absoluteString) {
// See comment in `encode(to:)`
urlComponents.percentEncodedQuery = urlComponents.percentEncodedQuery!.removingPercentEncoding
if let rewrittenQuery = urlComponents.url {
self.init(rewrittenQuery)
return
}
}
self.init(url)
}

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

private static let additionalQueryEncodingCharacterSet = CharacterSet(charactersIn: "?=&%").inverted

public func encode(to encoder: Encoder) throws {
try storage.absoluteString.encode(to: encoder)
let urlToEncode: URL
if let query = storage.query(percentEncoded: true), var components = URLComponents(string: storage.absoluteString) {
// 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:
//
// 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 `=` to 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
components.percentEncodedQuery =
query
.addingPercentEncoding(withAllowedCharacters: Self.additionalQueryEncodingCharacterSet)
if let componentsUrl = components.url {
urlToEncode = componentsUrl
} else {
urlToEncode = self.storage
}
} else {
urlToEncode = self.storage
}
try urlToEncode.absoluteString.encode(to: encoder)
}
}
9 changes: 6 additions & 3 deletions Sources/SKTestSupport/CheckCoding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,12 @@ package func checkCoding<T: Codable & Equatable>(
XCTAssertEqual(json, str, file: file, line: line)

let decoder = JSONDecoder()
let decodedValue = try! decoder.decode(WrapFragment<T>.self, from: data).value

XCTAssertEqual(value, decodedValue, file: file, line: line)
XCTAssertNoThrow(
try {
let decodedValue = try decoder.decode(WrapFragment<T>.self, from: data).value
XCTAssertEqual(value, decodedValue, file: file, line: line)
}()
)
}

/// JSONEncoder requires the top-level value to be encoded as a JSON container (array or object). Give it one.
Expand Down
23 changes: 23 additions & 0 deletions Tests/LanguageServerProtocolTests/CodingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1343,6 +1343,29 @@ final class CodingTests: XCTestCase {
"""
)
}

func testDocumentUriQueryParameterCoding() throws {
checkCoding(
try DocumentURI(string: "scheme://host?parent=scheme://host?line%3D2"),
json: #"""
"scheme:\/\/host?parent%3Dscheme:\/\/host%3Fline%253D2"
"""#
)

checkCoding(
try DocumentURI(string: "scheme://host?parent=scheme://host?parent%3Dscheme://host%3Fkey%253Dvalue"),
json: #"""
"scheme:\/\/host?parent%3Dscheme:\/\/host%3Fparent%253Dscheme:\/\/host%253Fkey%25253Dvalue"
"""#
)

checkCoding(
try DocumentURI(string: "scheme://host?parent=with%23hash"),
json: #"""
"scheme:\/\/host?parent%3Dwith%2523hash"
"""#
)
}
}

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