fix(opener): allow open network share locations (#3343)

* fix(opener): allow open network share locations

* Clippy

* Move to a seperate file

* Add license header

* test(opener): add unit tests for shell_parent_path and absolute functions (#1)

* Keep `absolute` non pub in `windows_shell_path`

* Add change file

---------

Co-authored-by: Mark Gandolfo <mark@gandolfo.com.au>
This commit is contained in:
Tony
2026-04-18 19:02:09 +08:00
committed by GitHub
parent 250857b7b7
commit c1fd33b3a2
6 changed files with 286 additions and 11 deletions
+6
View File
@@ -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
+1
View File
@@ -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]
+1
View File
@@ -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),
+2
View File
@@ -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<T> = std::result::Result<T, Error>;
+21 -11
View File
@@ -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<P: AsRef<Path>>(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<P: AsRef<Path>>(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<PathBuf> {
#[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<Cow<Path>, 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))
+255
View File
@@ -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<PathBuf> {
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<PathBuf> {
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<Cow<'_, Path>> {
fn handle_prefix(prefix: PrefixComponent<'_>) -> Option<Cow<'_, Path>> {
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:\")));
}
}