diff --git a/.changes/opener-windows-network-path.md b/.changes/opener-windows-network-path.md new file mode 100644 index 0000000000..a1c819c9da --- /dev/null +++ b/.changes/opener-windows-network-path.md @@ -0,0 +1,6 @@ +--- +"opener": patch +"opener-js": patch +--- + +Fix `revealItemInDir`/`reveal_items_in_dir` can't reveal network paths like `\\wsl.localhost\Ubuntu\etc` on Windows diff --git a/plugins/opener/Cargo.toml b/plugins/opener/Cargo.toml index 09ed3457cd..cb3b1b5110 100644 --- a/plugins/opener/Cargo.toml +++ b/plugins/opener/Cargo.toml @@ -41,6 +41,7 @@ features = [ "Win32_UI_WindowsAndMessaging", "Win32_System_Com", "Win32_System_Registry", + "Win32_Storage_FileSystem", ] [target."cfg(any(target_os = \"linux\", target_os = \"dragonfly\", target_os = \"freebsd\", target_os = \"netbsd\", target_os = \"openbsd\"))".dependencies] diff --git a/plugins/opener/src/error.rs b/plugins/opener/src/error.rs index c5a4dde3cc..23cd2a8aa8 100644 --- a/plugins/opener/src/error.rs +++ b/plugins/opener/src/error.rs @@ -31,6 +31,7 @@ pub enum Error { Win32Error(#[from] windows::core::Error), #[error("Path doesn't have a parent: {0}")] NoParent(PathBuf), + // TODO: Add the underlying io::Error to this variant #[cfg(windows)] #[error("Failed to convert path '{0}' to ITEMIDLIST")] FailedToConvertPathToItemIdList(PathBuf), diff --git a/plugins/opener/src/lib.rs b/plugins/opener/src/lib.rs index 1a8b6f0cb3..622a650976 100644 --- a/plugins/opener/src/lib.rs +++ b/plugins/opener/src/lib.rs @@ -20,6 +20,8 @@ mod open; mod reveal_item_in_dir; mod scope; mod scope_entry; +#[cfg(windows)] +mod windows_shell_path; pub use error::Error; type Result = std::result::Result; diff --git a/plugins/opener/src/reveal_item_in_dir.rs b/plugins/opener/src/reveal_item_in_dir.rs index 18f940c80a..b5da19060d 100644 --- a/plugins/opener/src/reveal_item_in_dir.rs +++ b/plugins/opener/src/reveal_item_in_dir.rs @@ -2,15 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -use std::path::Path; +use std::path::{Path, PathBuf}; -/// Reveal a path the system's default explorer. +/// Reveal a path in the system's default explorer. /// /// ## Platform-specific: /// /// - **Android / iOS:** Unsupported. pub fn reveal_item_in_dir>(path: P) -> crate::Result<()> { - let path = dunce::canonicalize(path.as_ref())?; + let path = canonicalize(path.as_ref())?; #[cfg(any( windows, @@ -35,7 +35,7 @@ pub fn reveal_item_in_dir>(path: P) -> crate::Result<()> { Err(crate::Error::UnsupportedPlatform) } -/// Reveal the paths the system's default explorer. +/// Reveal multiple paths in the system's default explorer. /// /// ## Platform-specific: /// @@ -48,7 +48,7 @@ where let mut canonicalized = vec![]; for path in paths { - let path = dunce::canonicalize(path.as_ref())?; + let path = canonicalize(path.as_ref())?; canonicalized.push(path); } @@ -75,10 +75,21 @@ where Err(crate::Error::UnsupportedPlatform) } +fn canonicalize(path: &Path) -> crate::Result { + #[cfg(windows)] + let path = crate::windows_shell_path::absolute_and_check_exists(dunce::simplified(path))?; + #[cfg(not(windows))] + let path = std::fs::canonicalize(path)?; + Ok(path) +} + #[cfg(windows)] mod imp { - use std::collections::HashMap; - use std::path::{Path, PathBuf}; + use std::{ + borrow::Cow, + collections::HashMap, + path::{Path, PathBuf}, + }; use windows::Win32::UI::Shell::Common::ITEMIDLIST; use windows::{ @@ -101,10 +112,9 @@ mod imp { return Ok(()); } - let mut grouped_paths: HashMap<&Path, Vec<&Path>> = HashMap::new(); + let mut grouped_paths: HashMap, Vec<&Path>> = HashMap::new(); for path in paths { - let parent = path - .parent() + let parent = crate::windows_shell_path::shell_parent_path(path) .ok_or_else(|| crate::Error::NoParent(path.to_path_buf()))?; grouped_paths.entry(parent).or_default().push(path); } @@ -112,7 +122,7 @@ mod imp { let _ = unsafe { CoInitialize(None) }; for (parent, to_reveals) in grouped_paths { - let parent_item_id_list = OwnedItemIdList::new(parent)?; + let parent_item_id_list = OwnedItemIdList::new(&parent)?; let to_reveals_item_id_list = to_reveals .iter() .map(|to_reveal| OwnedItemIdList::new(to_reveal)) diff --git a/plugins/opener/src/windows_shell_path.rs b/plugins/opener/src/windows_shell_path.rs new file mode 100644 index 0000000000..8715641dbe --- /dev/null +++ b/plugins/opener/src/windows_shell_path.rs @@ -0,0 +1,255 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::{ + borrow::Cow, + ffi::OsString, + io, + os::windows::ffi::OsStringExt, + path::{Component, Path, PathBuf, Prefix, PrefixComponent}, +}; + +use windows::{core::HSTRING, Win32::Storage::FileSystem::GetFullPathNameW}; + +pub fn absolute_and_check_exists(path: &Path) -> io::Result { + let path = absolute(path)?; + if path.exists() { + Ok(path) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "path doesn't exist", + )) + } +} + +// TODO: Switch to use `std::path::absolute` once MSRV > 1.79 +// Modified from https://github.com/rust-lang/rust/blob/b49ecc9eb70a51e89f32a7358e790f7b3808ccb3/library/std/src/sys/path/windows.rs#L185 +// Note: this doesn't resolve symlinks +fn absolute(path: &Path) -> io::Result { + if path.as_os_str().is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "cannot make an empty path absolute", + )); + } + + let prefix = path.components().next(); + // Verbatim paths should not be modified. + if prefix + .map(|component| { + let Component::Prefix(prefix) = component else { + return false; + }; + matches!( + prefix.kind(), + Prefix::Verbatim(..) | Prefix::VerbatimDisk(..) | Prefix::VerbatimUNC(..) + ) + }) + .unwrap_or(false) + { + // NULs in verbatim paths are rejected for consistency. + if path.as_os_str().as_encoded_bytes().contains(&0) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "strings passed to WinAPI cannot contain NULs", + )); + } + return Ok(path.to_owned()); + } + + // This is an additional check to make sure we don't pass in a single driver letter to GetFullPathNameW + // which will resolves to the current working directory + // + // > https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfullpathnamew#:~:text=If%20you%20specify%20%22U%3A%22%20the%20path%20returned%20is%20the%20current%20directory%20on%20the%20%22U%3A%5C%22%20drive + #[allow(clippy::collapsible_if)] + if let Some(Component::Prefix(last_prefix)) = path.components().next_back() { + if matches!(last_prefix.kind(), Prefix::Disk(..)) { + return Ok(PathBuf::from(last_prefix.as_os_str())); + } + } + + let path_hstring = HSTRING::from(path); + + let size = unsafe { GetFullPathNameW(&path_hstring, None, None) }; + if size == 0 { + return Err(io::Error::last_os_error()); + } + let mut buffer = vec![0; size as usize]; + let size = unsafe { GetFullPathNameW(&path_hstring, Some(&mut buffer), None) }; + if size == 0 { + return Err(io::Error::last_os_error()); + } + + Ok(PathBuf::from(OsString::from_wide(&buffer[..size as usize]))) +} + +/// Similar to [`Path::parent`] but resolves parent of `C:`/`C:\` to `""` and handles UNC host name (`\\wsl.localhost\Ubuntu\` to `\\wsl.localhost`) +pub fn shell_parent_path(path: &Path) -> Option> { + fn handle_prefix(prefix: PrefixComponent<'_>) -> Option> { + match prefix.kind() { + Prefix::UNC(host_name, _share_name) => { + let mut path = OsString::from(r"\\"); + path.push(host_name); + Some(PathBuf::from(path).into()) + } + Prefix::Disk(_) => Some(PathBuf::from("").into()), + _ => None, + } + } + + let mut components = path.components(); + let component = components.next_back()?; + match component { + Component::Normal(_) | Component::CurDir | Component::ParentDir => { + Some(components.as_path().into()) + } + Component::Prefix(prefix) => handle_prefix(prefix), + // Handle cases like `C:\` and `\\wsl.localhost\Ubuntu\` + Component::RootDir => { + if let Component::Prefix(prefix) = components.next_back()? { + handle_prefix(prefix) + } else { + None + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + // absolute() tests + + #[test] + fn absolute_empty_error() { + let err = absolute(Path::new("")).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + } + + #[test] + fn absolute_verbatim_passthrough() { + let path = Path::new(r"\\?\C:\foo"); + assert_eq!(absolute(path).unwrap(), path); + } + + #[test] + fn absolute_verbatim_unc_passthrough() { + let path = Path::new(r"\\?\UNC\server\share"); + assert_eq!(absolute(path).unwrap(), path); + } + + #[test] + fn absolute_bare_drive_letter() { + let result = absolute(Path::new("C:")).unwrap(); + assert_eq!(result, Path::new("C:")); + } + + #[test] + fn absolute_already_absolute() { + let result = absolute(Path::new(r"C:\Windows")).unwrap(); + assert_eq!(result, Path::new(r"C:\Windows")); + } + + #[test] + fn absolute_unc_path() { + let result = absolute(Path::new(r"\\server\share\folder")).unwrap(); + assert_eq!(result, Path::new(r"\\server\share\folder")); + } + + #[test] + fn absolute_converts_forward_slashes() { + let result = absolute(Path::new("C:/Windows/System32")).unwrap(); + assert_eq!(result, Path::new(r"C:\Windows\System32")); + } + + // absolute_and_check_exists() tests + + #[test] + fn absolute_and_check_exists_existing_path() { + assert!(absolute_and_check_exists(Path::new(r"C:\Windows")).is_ok()); + } + + #[test] + fn absolute_and_check_exists_nonexistent_path() { + let err = absolute_and_check_exists(Path::new(r"C:\nonexistent_xyz_12345")).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + } + + #[test] + fn absolute_and_check_exists_empty_propagates() { + let err = absolute_and_check_exists(Path::new("")).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + } + + // shell_parent_path() tests + + #[test] + fn shell_parent_path_local_path() { + let result = shell_parent_path(Path::new(r"C:\Users\foo")); + assert_eq!(result.as_deref(), Some(Path::new(r"C:\Users"))); + } + + #[test] + fn shell_parent_path_nested_path() { + let result = shell_parent_path(Path::new(r"C:\a\b\c\d")); + assert_eq!(result.as_deref(), Some(Path::new(r"C:\a\b\c"))); + } + + #[test] + fn shell_parent_path_drive_root_trailing() { + let result = shell_parent_path(Path::new(r"C:\")); + assert_eq!(result.as_deref(), Some(Path::new(""))); + } + + #[test] + fn shell_parent_path_bare_drive() { + let result = shell_parent_path(Path::new("C:")); + assert_eq!(result.as_deref(), Some(Path::new(""))); + } + + #[test] + fn shell_parent_path_unc_with_subfolder() { + let result = shell_parent_path(Path::new(r"\\server\share\folder")); + assert_eq!(result.as_deref(), Some(Path::new(r"\\server\share"))); + } + + #[test] + fn shell_parent_path_unc_share_trailing_slash() { + let result = shell_parent_path(Path::new(r"\\server.local\share\")); + assert_eq!(result.as_deref(), Some(Path::new(r"\\server.local"))); + } + + #[test] + fn shell_parent_path_unc_share_no_slash() { + let result = shell_parent_path(Path::new(r"\\server\share")); + assert_eq!(result.as_deref(), Some(Path::new(r"\\server"))); + } + + #[test] + fn shell_parent_path_relative() { + let result = shell_parent_path(Path::new(r"foo\bar")); + assert_eq!(result.as_deref(), Some(Path::new("foo"))); + } + + #[test] + fn shell_parent_path_single_component() { + let result = shell_parent_path(Path::new("foo")); + assert_eq!(result.as_deref(), Some(Path::new(""))); + } + + #[test] + fn shell_parent_path_empty() { + let result = shell_parent_path(Path::new("")); + assert!(result.is_none()); + } + + #[test] + fn shell_parent_path_verbatim() { + let result = shell_parent_path(Path::new(r"\\?\C:\foo")); + assert_eq!(result.as_deref(), Some(Path::new(r"\\?\C:\"))); + } +}