From 07196a47adb703a563af64e2e1aab61c8694f2e4 Mon Sep 17 00:00:00 2001 From: Ivan Dergachev Date: Fri, 8 May 2026 22:42:45 +0400 Subject: [PATCH] fix(core): encode reserved path chars in file URIs --- crates/mcpls-core/src/bridge/state.rs | 67 +++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 9 deletions(-) diff --git a/crates/mcpls-core/src/bridge/state.rs b/crates/mcpls-core/src/bridge/state.rs index e448309..480e563 100644 --- a/crates/mcpls-core/src/bridge/state.rs +++ b/crates/mcpls-core/src/bridge/state.rs @@ -212,19 +212,46 @@ impl DocumentTracker { /// not occur for valid absolute paths. #[must_use] pub fn path_to_uri(path: &Path) -> Uri { - let uri_string = if cfg!(windows) { - let path_str = path.to_string_lossy(); - // canonicalize() on Windows adds a \\?\ extended-path prefix. - // Strip it before building the URI — file:////?\C:/ is not valid. - let stripped = path_str.strip_prefix(r"\\?\").unwrap_or(&path_str); - format!("file:///{}", stripped.replace('\\', "/")) - } else { - format!("file://{}", path.display()) - }; + let uri_string = file_uri_string(path); + let uri_string = encode_rfc3986_path_chars(&uri_string); #[allow(clippy::expect_used)] uri_string.parse().expect("failed to create URI from path") } +#[cfg(not(windows))] +fn file_uri_string(path: &Path) -> String { + #[allow(clippy::expect_used)] + let file_url = Url::from_file_path(path).expect("failed to create file URI from path"); + file_url.into() +} + +#[cfg(windows)] +fn file_uri_string(path: &Path) -> String { + match Url::from_file_path(path) { + Ok(file_url) => file_url.into(), + Err(()) if path.has_root() => windows_rooted_path_to_file_uri(path), + Err(()) => panic!("failed to create file URI from path"), + } +} + +#[cfg(windows)] +fn windows_rooted_path_to_file_uri(path: &Path) -> String { + let path_str = path.to_string_lossy(); + let stripped = path_str.strip_prefix(r"\\?\").unwrap_or(&path_str); + format!("file:///{}", stripped.replace('\\', "/")) +} + +fn encode_rfc3986_path_chars(uri: &str) -> String { + let path_start = url::Position::BeforePath as usize; + let (prefix, path) = uri.split_at(path_start); + let encoded_path = path + .replace('[', "%5B") + .replace(']', "%5D") + .replace('^', "%5E") + .replace('|', "%7C"); + format!("{prefix}{encoded_path}") +} + /// Convert an LSP `file://` URI to an absolute filesystem path. /// /// Returns `None` if the URI is not a valid `file://` URI, uses a non-file @@ -636,6 +663,28 @@ mod tests { assert!(uri.as_str().contains("project-test")); } + #[test] + fn test_path_to_uri_percent_encodes_reserved_chars() { + #[cfg(windows)] + let path = Path::new(r"C:\home\user\routes\api\[...]^|.ts"); + #[cfg(not(windows))] + let path = Path::new("/home/user/routes/api/[...]^|.ts"); + + let uri = path_to_uri(path); + + #[cfg(windows)] + let expected = "file:///C:/home/user/routes/api/%5B...%5D%5E%7C.ts"; + #[cfg(not(windows))] + let expected = "file:///home/user/routes/api/%5B...%5D%5E%7C.ts"; + + assert_eq!(uri.as_str(), expected); + assert_eq!( + uri_to_path(&uri).as_deref(), + Some(path), + "encoded file URI should round-trip to the original path" + ); + } + #[test] fn test_document_tracker_concurrent_operations() { let mut map = HashMap::new();