Skip to content

Commit 9785314

Browse files
committed
mason: percent-decode Windows LSP URI segments in url_to_path
The drive-letter and UNC branches of url_to_path copied the URL's serialized, percent-encoded text straight into a PathBuf, so file:///C:/repo/space%20dir/main.rs became the nonexistent path C:\repo\space%20dir\main.rs. Push diagnostics echoed by servers were then cached under the wrong key and never surfaced for the real file. Decode each path segment before pushing it, in both branches. Decoding is per-segment (not whole-path) so an encoded %2F cannot smuggle a separator: a decoded segment containing '/', '\\', or NUL is rejected. Invalid UTF-8 after decoding is an error, not a lossy replacement, because a wrong path key silently eats diagnostics. Unescaped ASCII paths decode byte-identically to before. Added cross-platform (not cfg(windows)-gated) round-trip tests for drive and UNC paths with spaces and UTF-8, a %2F smuggling rejection, and a byte-identity regression guard.
1 parent 122f928 commit 9785314

1 file changed

Lines changed: 127 additions & 4 deletions

File tree

crates/aft/src/lsp/position.rs

Lines changed: 127 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
204263
fn 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

Comments
 (0)