Skip to content

Commit c1fd33b

Browse files
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>
1 parent 250857b commit c1fd33b

6 files changed

Lines changed: 286 additions & 11 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"opener": patch
3+
"opener-js": patch
4+
---
5+
6+
Fix `revealItemInDir`/`reveal_items_in_dir` can't reveal network paths like `\\wsl.localhost\Ubuntu\etc` on Windows

plugins/opener/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ features = [
4141
"Win32_UI_WindowsAndMessaging",
4242
"Win32_System_Com",
4343
"Win32_System_Registry",
44+
"Win32_Storage_FileSystem",
4445
]
4546

4647
[target."cfg(any(target_os = \"linux\", target_os = \"dragonfly\", target_os = \"freebsd\", target_os = \"netbsd\", target_os = \"openbsd\"))".dependencies]

plugins/opener/src/error.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ pub enum Error {
3131
Win32Error(#[from] windows::core::Error),
3232
#[error("Path doesn't have a parent: {0}")]
3333
NoParent(PathBuf),
34+
// TODO: Add the underlying io::Error to this variant
3435
#[cfg(windows)]
3536
#[error("Failed to convert path '{0}' to ITEMIDLIST")]
3637
FailedToConvertPathToItemIdList(PathBuf),

plugins/opener/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ mod open;
2020
mod reveal_item_in_dir;
2121
mod scope;
2222
mod scope_entry;
23+
#[cfg(windows)]
24+
mod windows_shell_path;
2325

2426
pub use error::Error;
2527
type Result<T> = std::result::Result<T, Error>;

plugins/opener/src/reveal_item_in_dir.rs

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22
// SPDX-License-Identifier: Apache-2.0
33
// SPDX-License-Identifier: MIT
44

5-
use std::path::Path;
5+
use std::path::{Path, PathBuf};
66

7-
/// Reveal a path the system's default explorer.
7+
/// Reveal a path in the system's default explorer.
88
///
99
/// ## Platform-specific:
1010
///
1111
/// - **Android / iOS:** Unsupported.
1212
pub fn reveal_item_in_dir<P: AsRef<Path>>(path: P) -> crate::Result<()> {
13-
let path = dunce::canonicalize(path.as_ref())?;
13+
let path = canonicalize(path.as_ref())?;
1414

1515
#[cfg(any(
1616
windows,
@@ -35,7 +35,7 @@ pub fn reveal_item_in_dir<P: AsRef<Path>>(path: P) -> crate::Result<()> {
3535
Err(crate::Error::UnsupportedPlatform)
3636
}
3737

38-
/// Reveal the paths the system's default explorer.
38+
/// Reveal multiple paths in the system's default explorer.
3939
///
4040
/// ## Platform-specific:
4141
///
@@ -48,7 +48,7 @@ where
4848
let mut canonicalized = vec![];
4949

5050
for path in paths {
51-
let path = dunce::canonicalize(path.as_ref())?;
51+
let path = canonicalize(path.as_ref())?;
5252
canonicalized.push(path);
5353
}
5454

@@ -75,10 +75,21 @@ where
7575
Err(crate::Error::UnsupportedPlatform)
7676
}
7777

78+
fn canonicalize(path: &Path) -> crate::Result<PathBuf> {
79+
#[cfg(windows)]
80+
let path = crate::windows_shell_path::absolute_and_check_exists(dunce::simplified(path))?;
81+
#[cfg(not(windows))]
82+
let path = std::fs::canonicalize(path)?;
83+
Ok(path)
84+
}
85+
7886
#[cfg(windows)]
7987
mod imp {
80-
use std::collections::HashMap;
81-
use std::path::{Path, PathBuf};
88+
use std::{
89+
borrow::Cow,
90+
collections::HashMap,
91+
path::{Path, PathBuf},
92+
};
8293

8394
use windows::Win32::UI::Shell::Common::ITEMIDLIST;
8495
use windows::{
@@ -101,18 +112,17 @@ mod imp {
101112
return Ok(());
102113
}
103114

104-
let mut grouped_paths: HashMap<&Path, Vec<&Path>> = HashMap::new();
115+
let mut grouped_paths: HashMap<Cow<Path>, Vec<&Path>> = HashMap::new();
105116
for path in paths {
106-
let parent = path
107-
.parent()
117+
let parent = crate::windows_shell_path::shell_parent_path(path)
108118
.ok_or_else(|| crate::Error::NoParent(path.to_path_buf()))?;
109119
grouped_paths.entry(parent).or_default().push(path);
110120
}
111121

112122
let _ = unsafe { CoInitialize(None) };
113123

114124
for (parent, to_reveals) in grouped_paths {
115-
let parent_item_id_list = OwnedItemIdList::new(parent)?;
125+
let parent_item_id_list = OwnedItemIdList::new(&parent)?;
116126
let to_reveals_item_id_list = to_reveals
117127
.iter()
118128
.map(|to_reveal| OwnedItemIdList::new(to_reveal))
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
2+
// SPDX-License-Identifier: Apache-2.0
3+
// SPDX-License-Identifier: MIT
4+
5+
use std::{
6+
borrow::Cow,
7+
ffi::OsString,
8+
io,
9+
os::windows::ffi::OsStringExt,
10+
path::{Component, Path, PathBuf, Prefix, PrefixComponent},
11+
};
12+
13+
use windows::{core::HSTRING, Win32::Storage::FileSystem::GetFullPathNameW};
14+
15+
pub fn absolute_and_check_exists(path: &Path) -> io::Result<PathBuf> {
16+
let path = absolute(path)?;
17+
if path.exists() {
18+
Ok(path)
19+
} else {
20+
Err(std::io::Error::new(
21+
std::io::ErrorKind::NotFound,
22+
"path doesn't exist",
23+
))
24+
}
25+
}
26+
27+
// TODO: Switch to use `std::path::absolute` once MSRV > 1.79
28+
// Modified from https://github.com/rust-lang/rust/blob/b49ecc9eb70a51e89f32a7358e790f7b3808ccb3/library/std/src/sys/path/windows.rs#L185
29+
// Note: this doesn't resolve symlinks
30+
fn absolute(path: &Path) -> io::Result<PathBuf> {
31+
if path.as_os_str().is_empty() {
32+
return Err(io::Error::new(
33+
io::ErrorKind::InvalidInput,
34+
"cannot make an empty path absolute",
35+
));
36+
}
37+
38+
let prefix = path.components().next();
39+
// Verbatim paths should not be modified.
40+
if prefix
41+
.map(|component| {
42+
let Component::Prefix(prefix) = component else {
43+
return false;
44+
};
45+
matches!(
46+
prefix.kind(),
47+
Prefix::Verbatim(..) | Prefix::VerbatimDisk(..) | Prefix::VerbatimUNC(..)
48+
)
49+
})
50+
.unwrap_or(false)
51+
{
52+
// NULs in verbatim paths are rejected for consistency.
53+
if path.as_os_str().as_encoded_bytes().contains(&0) {
54+
return Err(io::Error::new(
55+
io::ErrorKind::InvalidInput,
56+
"strings passed to WinAPI cannot contain NULs",
57+
));
58+
}
59+
return Ok(path.to_owned());
60+
}
61+
62+
// This is an additional check to make sure we don't pass in a single driver letter to GetFullPathNameW
63+
// which will resolves to the current working directory
64+
//
65+
// > 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
66+
#[allow(clippy::collapsible_if)]
67+
if let Some(Component::Prefix(last_prefix)) = path.components().next_back() {
68+
if matches!(last_prefix.kind(), Prefix::Disk(..)) {
69+
return Ok(PathBuf::from(last_prefix.as_os_str()));
70+
}
71+
}
72+
73+
let path_hstring = HSTRING::from(path);
74+
75+
let size = unsafe { GetFullPathNameW(&path_hstring, None, None) };
76+
if size == 0 {
77+
return Err(io::Error::last_os_error());
78+
}
79+
let mut buffer = vec![0; size as usize];
80+
let size = unsafe { GetFullPathNameW(&path_hstring, Some(&mut buffer), None) };
81+
if size == 0 {
82+
return Err(io::Error::last_os_error());
83+
}
84+
85+
Ok(PathBuf::from(OsString::from_wide(&buffer[..size as usize])))
86+
}
87+
88+
/// Similar to [`Path::parent`] but resolves parent of `C:`/`C:\` to `""` and handles UNC host name (`\\wsl.localhost\Ubuntu\` to `\\wsl.localhost`)
89+
pub fn shell_parent_path(path: &Path) -> Option<Cow<'_, Path>> {
90+
fn handle_prefix(prefix: PrefixComponent<'_>) -> Option<Cow<'_, Path>> {
91+
match prefix.kind() {
92+
Prefix::UNC(host_name, _share_name) => {
93+
let mut path = OsString::from(r"\\");
94+
path.push(host_name);
95+
Some(PathBuf::from(path).into())
96+
}
97+
Prefix::Disk(_) => Some(PathBuf::from("").into()),
98+
_ => None,
99+
}
100+
}
101+
102+
let mut components = path.components();
103+
let component = components.next_back()?;
104+
match component {
105+
Component::Normal(_) | Component::CurDir | Component::ParentDir => {
106+
Some(components.as_path().into())
107+
}
108+
Component::Prefix(prefix) => handle_prefix(prefix),
109+
// Handle cases like `C:\` and `\\wsl.localhost\Ubuntu\`
110+
Component::RootDir => {
111+
if let Component::Prefix(prefix) = components.next_back()? {
112+
handle_prefix(prefix)
113+
} else {
114+
None
115+
}
116+
}
117+
}
118+
}
119+
120+
#[cfg(test)]
121+
mod tests {
122+
use super::*;
123+
use std::path::Path;
124+
125+
// absolute() tests
126+
127+
#[test]
128+
fn absolute_empty_error() {
129+
let err = absolute(Path::new("")).unwrap_err();
130+
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
131+
}
132+
133+
#[test]
134+
fn absolute_verbatim_passthrough() {
135+
let path = Path::new(r"\\?\C:\foo");
136+
assert_eq!(absolute(path).unwrap(), path);
137+
}
138+
139+
#[test]
140+
fn absolute_verbatim_unc_passthrough() {
141+
let path = Path::new(r"\\?\UNC\server\share");
142+
assert_eq!(absolute(path).unwrap(), path);
143+
}
144+
145+
#[test]
146+
fn absolute_bare_drive_letter() {
147+
let result = absolute(Path::new("C:")).unwrap();
148+
assert_eq!(result, Path::new("C:"));
149+
}
150+
151+
#[test]
152+
fn absolute_already_absolute() {
153+
let result = absolute(Path::new(r"C:\Windows")).unwrap();
154+
assert_eq!(result, Path::new(r"C:\Windows"));
155+
}
156+
157+
#[test]
158+
fn absolute_unc_path() {
159+
let result = absolute(Path::new(r"\\server\share\folder")).unwrap();
160+
assert_eq!(result, Path::new(r"\\server\share\folder"));
161+
}
162+
163+
#[test]
164+
fn absolute_converts_forward_slashes() {
165+
let result = absolute(Path::new("C:/Windows/System32")).unwrap();
166+
assert_eq!(result, Path::new(r"C:\Windows\System32"));
167+
}
168+
169+
// absolute_and_check_exists() tests
170+
171+
#[test]
172+
fn absolute_and_check_exists_existing_path() {
173+
assert!(absolute_and_check_exists(Path::new(r"C:\Windows")).is_ok());
174+
}
175+
176+
#[test]
177+
fn absolute_and_check_exists_nonexistent_path() {
178+
let err = absolute_and_check_exists(Path::new(r"C:\nonexistent_xyz_12345")).unwrap_err();
179+
assert_eq!(err.kind(), io::ErrorKind::NotFound);
180+
}
181+
182+
#[test]
183+
fn absolute_and_check_exists_empty_propagates() {
184+
let err = absolute_and_check_exists(Path::new("")).unwrap_err();
185+
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
186+
}
187+
188+
// shell_parent_path() tests
189+
190+
#[test]
191+
fn shell_parent_path_local_path() {
192+
let result = shell_parent_path(Path::new(r"C:\Users\foo"));
193+
assert_eq!(result.as_deref(), Some(Path::new(r"C:\Users")));
194+
}
195+
196+
#[test]
197+
fn shell_parent_path_nested_path() {
198+
let result = shell_parent_path(Path::new(r"C:\a\b\c\d"));
199+
assert_eq!(result.as_deref(), Some(Path::new(r"C:\a\b\c")));
200+
}
201+
202+
#[test]
203+
fn shell_parent_path_drive_root_trailing() {
204+
let result = shell_parent_path(Path::new(r"C:\"));
205+
assert_eq!(result.as_deref(), Some(Path::new("")));
206+
}
207+
208+
#[test]
209+
fn shell_parent_path_bare_drive() {
210+
let result = shell_parent_path(Path::new("C:"));
211+
assert_eq!(result.as_deref(), Some(Path::new("")));
212+
}
213+
214+
#[test]
215+
fn shell_parent_path_unc_with_subfolder() {
216+
let result = shell_parent_path(Path::new(r"\\server\share\folder"));
217+
assert_eq!(result.as_deref(), Some(Path::new(r"\\server\share")));
218+
}
219+
220+
#[test]
221+
fn shell_parent_path_unc_share_trailing_slash() {
222+
let result = shell_parent_path(Path::new(r"\\server.local\share\"));
223+
assert_eq!(result.as_deref(), Some(Path::new(r"\\server.local")));
224+
}
225+
226+
#[test]
227+
fn shell_parent_path_unc_share_no_slash() {
228+
let result = shell_parent_path(Path::new(r"\\server\share"));
229+
assert_eq!(result.as_deref(), Some(Path::new(r"\\server")));
230+
}
231+
232+
#[test]
233+
fn shell_parent_path_relative() {
234+
let result = shell_parent_path(Path::new(r"foo\bar"));
235+
assert_eq!(result.as_deref(), Some(Path::new("foo")));
236+
}
237+
238+
#[test]
239+
fn shell_parent_path_single_component() {
240+
let result = shell_parent_path(Path::new("foo"));
241+
assert_eq!(result.as_deref(), Some(Path::new("")));
242+
}
243+
244+
#[test]
245+
fn shell_parent_path_empty() {
246+
let result = shell_parent_path(Path::new(""));
247+
assert!(result.is_none());
248+
}
249+
250+
#[test]
251+
fn shell_parent_path_verbatim() {
252+
let result = shell_parent_path(Path::new(r"\\?\C:\foo"));
253+
assert_eq!(result.as_deref(), Some(Path::new(r"\\?\C:\")));
254+
}
255+
}

0 commit comments

Comments
 (0)