@@ -115,16 +115,22 @@ pub fn url_to_path(url: &Url) -> Result<PathBuf, LspError> {
115115 continue ;
116116 }
117117 path. push ( '\\' ) ;
118- path. push_str ( segment) ;
118+ path. push_str ( & decode_uri_segment ( segment) ? ) ;
119119 }
120120 return Ok ( normalize_lookup_path ( & PathBuf :: from ( path) ) ) ;
121121 }
122122
123123 let path = url. path ( ) ;
124124 if path. len ( ) >= 4 && path. as_bytes ( ) [ 0 ] == b'/' && is_ascii_drive_prefix ( & path[ 1 ..] ) {
125- return Ok ( normalize_lookup_path ( & PathBuf :: from (
126- path[ 1 ..] . replace ( '/' , "\\ " ) ,
127- ) ) ) ;
125+ // Decode each '/'-delimited segment separately and rejoin with '\\'.
126+ // Splitting before decoding (instead of decoding the whole path) keeps
127+ // an encoded `%2F` inside one segment from becoming a real separator.
128+ let decoded = path[ 1 ..]
129+ . split ( '/' )
130+ . map ( decode_uri_segment)
131+ . collect :: < Result < Vec < _ > , _ > > ( ) ?
132+ . join ( "\\ " ) ;
133+ return Ok ( normalize_lookup_path ( & PathBuf :: from ( decoded) ) ) ;
128134 }
129135
130136 url. to_file_path ( )
@@ -201,6 +207,59 @@ fn encode_uri_path(path: &str) -> String {
201207 . join ( "/" )
202208}
203209
210+ /// Percent-decode a single URI path segment into one filesystem path component.
211+ ///
212+ /// Decoding is per segment, never over a whole path, so an encoded separator
213+ /// (`%2F` for '/' or `%5C` for '\\') inside a segment is decoded and then
214+ /// rejected rather than silently splitting the path. Whole-path decoding would
215+ /// let a server that echoes our URIs smuggle extra directory levels (or a NUL)
216+ /// into the path we cache diagnostics under. A malformed `%` escape that is not
217+ /// followed by two hex digits is copied through literally, matching how the
218+ /// `url` crate serializes such input.
219+ fn decode_uri_segment ( segment : & str ) -> Result < String , LspError > {
220+ let bytes = segment. as_bytes ( ) ;
221+ let mut decoded = Vec :: with_capacity ( bytes. len ( ) ) ;
222+ let mut i = 0 ;
223+ while i < bytes. len ( ) {
224+ if bytes[ i] == b'%' {
225+ let hi = bytes. get ( i + 1 ) . and_then ( hex_value) ;
226+ let lo = bytes. get ( i + 2 ) . and_then ( hex_value) ;
227+ if let ( Some ( hi) , Some ( lo) ) = ( hi, lo) {
228+ decoded. push ( ( hi << 4 ) | lo) ;
229+ i += 3 ;
230+ continue ;
231+ }
232+ }
233+ decoded. push ( bytes[ i] ) ;
234+ i += 1 ;
235+ }
236+
237+ // Refuse invalid UTF-8 outright: a wrong path key silently eats push
238+ // diagnostics, so a refused path is safer than a lossily-replaced one.
239+ let decoded = String :: from_utf8 ( decoded) . map_err ( |_| {
240+ LspError :: NotFound ( format ! (
241+ "URI segment '{segment}' is not valid UTF-8 after percent-decoding"
242+ ) )
243+ } ) ?;
244+
245+ if decoded. contains ( [ '/' , '\\' , '\0' ] ) {
246+ return Err ( LspError :: NotFound ( format ! (
247+ "URI segment '{segment}' decodes to a path separator or NUL"
248+ ) ) ) ;
249+ }
250+
251+ Ok ( decoded)
252+ }
253+
254+ fn hex_value ( byte : & u8 ) -> Option < u8 > {
255+ match byte {
256+ b'0' ..=b'9' => Some ( byte - b'0' ) ,
257+ b'a' ..=b'f' => Some ( byte - b'a' + 10 ) ,
258+ b'A' ..=b'F' => Some ( byte - b'A' + 10 ) ,
259+ _ => None ,
260+ }
261+ }
262+
204263fn path_display ( path : & str ) -> String {
205264 path. replace ( '/' , std:: path:: MAIN_SEPARATOR_STR )
206265}
@@ -256,4 +315,68 @@ mod tests {
256315 assert_eq ! ( uri. as_str( ) , "file:///tmp/aft-lsp-position.rs" ) ;
257316 assert_eq ! ( url_to_path( & uri) . expect( "path" ) , PathBuf :: from( path) ) ;
258317 }
318+
319+ // The drive-letter and UNC decode branches below run on every platform
320+ // whenever a drive/UNC-shaped file URI is handed in, so these tests are
321+ // deliberately NOT cfg(windows)-gated: a regression must fail CI on Linux
322+ // too, not only on Windows runners.
323+
324+ // Regression guard for the common case: an already-unescaped ASCII path
325+ // must decode to byte-identical output (no behavior change from adding
326+ // percent-decoding).
327+ #[ test]
328+ fn plain_ascii_drive_uri_is_byte_identical ( ) {
329+ let url = Url :: parse ( "file:///C:/repo/src/main.rs" ) . expect ( "url" ) ;
330+ assert_eq ! (
331+ url_to_path( & url) . expect( "path" ) ,
332+ PathBuf :: from( r"C:\repo\src\main.rs" )
333+ ) ;
334+ }
335+
336+ #[ test]
337+ fn drive_uri_with_space_decodes ( ) {
338+ let url = Url :: parse ( "file:///C:/repo/space%20dir/main.rs" ) . expect ( "url" ) ;
339+ assert_eq ! (
340+ url_to_path( & url) . expect( "path" ) ,
341+ PathBuf :: from( r"C:\repo\space dir\main.rs" )
342+ ) ;
343+ }
344+
345+ #[ test]
346+ fn drive_uri_with_utf8_segment_decodes ( ) {
347+ // %C3%A9 is the UTF-8 encoding of 'é'.
348+ let url = Url :: parse ( "file:///C:/repo/caf%C3%A9/main.rs" ) . expect ( "url" ) ;
349+ assert_eq ! (
350+ url_to_path( & url) . expect( "path" ) ,
351+ PathBuf :: from( "C:\\ repo\\ café\\ main.rs" )
352+ ) ;
353+ }
354+
355+ #[ test]
356+ fn unc_uri_with_space_decodes ( ) {
357+ let url = Url :: parse ( "file://server/share/space%20dir/file.rs" ) . expect ( "url" ) ;
358+ assert_eq ! (
359+ url_to_path( & url) . expect( "path" ) ,
360+ PathBuf :: from( r"\\server\share\space dir\file.rs" )
361+ ) ;
362+ }
363+
364+ #[ test]
365+ fn unc_uri_with_utf8_segment_decodes ( ) {
366+ let url = Url :: parse ( "file://server/share/caf%C3%A9/file.rs" ) . expect ( "url" ) ;
367+ assert_eq ! (
368+ url_to_path( & url) . expect( "path" ) ,
369+ PathBuf :: from( r"\\server\share\café\file.rs" )
370+ ) ;
371+ }
372+
373+ #[ test]
374+ fn encoded_separator_in_segment_is_rejected ( ) {
375+ // An encoded '%2F' must not smuggle a '/' into a single path component.
376+ let drive = Url :: parse ( "file:///C:/repo/a%2Fb/main.rs" ) . expect ( "url" ) ;
377+ assert ! ( url_to_path( & drive) . is_err( ) ) ;
378+
379+ let unc = Url :: parse ( "file://server/share/a%2Fb/file.rs" ) . expect ( "url" ) ;
380+ assert ! ( url_to_path( & unc) . is_err( ) ) ;
381+ }
259382}
0 commit comments