Skip to content

Commit 6f0b737

Browse files
authored
fix: Support for non-UTF8 paths on HFS+ on MacOS
Now illegal UTF8 is percent-encoded. Previously this code would have panicked.
2 parents 47ed29d + d23a591 commit 6f0b737

File tree

3 files changed

+125
-20
lines changed

3 files changed

+125
-20
lines changed

Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
2-
31
[package]
42
name = "trash"
53
version = "5.2.0"
@@ -29,6 +27,7 @@ rand = "0.8.5"
2927
once_cell = "1.18.0"
3028
env_logger = "0.10.0"
3129
tempfile = "3.8.0"
30+
defer = "0.2.1"
3231

3332

3433
[target.'cfg(target_os = "macos")'.dependencies]
@@ -39,6 +38,7 @@ objc2-foundation = { version = "0.2.0", features = [
3938
"NSString",
4039
"NSURL",
4140
] }
41+
percent-encoding = "2.3.1"
4242

4343
[target.'cfg(all(unix, not(target_os = "macos"), not(target_os = "ios"), not(target_os = "android")))'.dependencies]
4444
chrono = { version = "0.4.31", optional = true, default-features = false, features = [

src/macos.rs

Lines changed: 122 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
use std::{ffi::OsString, path::PathBuf, process::Command};
1+
use std::{
2+
ffi::OsString,
3+
path::{Path, PathBuf},
4+
process::Command,
5+
};
26

37
use log::trace;
48
use objc2_foundation::{NSFileManager, NSString, NSURL};
@@ -71,22 +75,25 @@ impl TrashContextExtMacos for TrashContext {
7175
}
7276
impl TrashContext {
7377
pub(crate) fn delete_all_canonicalized(&self, full_paths: Vec<PathBuf>) -> Result<(), Error> {
74-
let full_paths = full_paths.into_iter().map(to_string).collect::<Result<Vec<_>, _>>()?;
7578
match self.platform_specific.delete_method {
76-
DeleteMethod::Finder => delete_using_finder(full_paths),
77-
DeleteMethod::NsFileManager => delete_using_file_mgr(full_paths),
79+
DeleteMethod::Finder => delete_using_finder(&full_paths),
80+
DeleteMethod::NsFileManager => delete_using_file_mgr(&full_paths),
7881
}
7982
}
8083
}
8184

82-
fn delete_using_file_mgr(full_paths: Vec<String>) -> Result<(), Error> {
85+
fn delete_using_file_mgr<P: AsRef<Path>>(full_paths: &[P]) -> Result<(), Error> {
8386
trace!("Starting delete_using_file_mgr");
8487
let file_mgr = unsafe { NSFileManager::defaultManager() };
8588
for path in full_paths {
86-
let string = NSString::from_str(&path);
89+
let path = path.as_ref().as_os_str().as_encoded_bytes();
90+
let path = match std::str::from_utf8(path) {
91+
Ok(path_utf8) => NSString::from_str(path_utf8), // utf-8 path, use as is
92+
Err(_) => NSString::from_str(&percent_encode(path)), // binary path, %-encode it
93+
};
8794

8895
trace!("Starting fileURLWithPath");
89-
let url = unsafe { NSURL::fileURLWithPath(&string) };
96+
let url = unsafe { NSURL::fileURLWithPath(&path) };
9097
trace!("Finished fileURLWithPath");
9198

9299
trace!("Calling trashItemAtURL");
@@ -95,19 +102,29 @@ fn delete_using_file_mgr(full_paths: Vec<String>) -> Result<(), Error> {
95102

96103
if let Err(err) = res {
97104
return Err(Error::Unknown {
98-
description: format!("While deleting '{path}', `trashItemAtURL` failed: {err}"),
105+
description: format!("While deleting '{:?}', `trashItemAtURL` failed: {err}", path.as_ref()),
99106
});
100107
}
101108
}
102109
Ok(())
103110
}
104111

105-
fn delete_using_finder(full_paths: Vec<String>) -> Result<(), Error> {
112+
fn delete_using_finder<P: AsRef<Path>>(full_paths: &[P]) -> Result<(), Error> {
106113
// AppleScript command to move files (or directories) to Trash looks like
107114
// osascript -e 'tell application "Finder" to delete { POSIX file "file1", POSIX "file2" }'
108115
// The `-e` flag is used to execute only one line of AppleScript.
109116
let mut command = Command::new("osascript");
110-
let posix_files = full_paths.into_iter().map(|p| format!("POSIX file \"{p}\"")).collect::<Vec<String>>().join(", ");
117+
let posix_files = full_paths
118+
.iter()
119+
.map(|p| {
120+
let path_b = p.as_ref().as_os_str().as_encoded_bytes();
121+
match std::str::from_utf8(path_b) {
122+
Ok(path_utf8) => format!("POSIX file \"{path_utf8}\""), // utf-8 path, use as is
123+
Err(_) => format!("POSIX file \"{}\"", &percent_encode(path_b)), // binary path, %-encode it
124+
}
125+
})
126+
.collect::<Vec<String>>()
127+
.join(", ");
111128
let script = format!("tell application \"Finder\" to delete {{ {posix_files} }}");
112129

113130
let argv: Vec<OsString> = vec!["-e".into(), script.into()];
@@ -135,24 +152,47 @@ fn delete_using_finder(full_paths: Vec<String>) -> Result<(), Error> {
135152
Ok(())
136153
}
137154

138-
fn to_string<T: Into<OsString>>(str_in: T) -> Result<String, Error> {
139-
let os_string = str_in.into();
140-
let s = os_string.to_str();
141-
match s {
142-
Some(s) => Ok(s.to_owned()),
143-
None => Err(Error::ConvertOsString { original: os_string }),
155+
/// std's from_utf8_lossy, but non-utf8 byte sequences are %-encoded instead of being replaced by a special symbol.
156+
/// Valid utf8, including `%`, are not escaped.
157+
use std::borrow::Cow;
158+
fn percent_encode(input: &[u8]) -> Cow<'_, str> {
159+
use percent_encoding::percent_encode_byte as b2pc;
160+
161+
let mut iter = input.utf8_chunks().peekable();
162+
if let Some(chunk) = iter.peek() {
163+
if chunk.invalid().is_empty() {
164+
return Cow::Borrowed(chunk.valid());
165+
}
166+
} else {
167+
return Cow::Borrowed("");
168+
};
169+
170+
let mut res = String::with_capacity(input.len());
171+
for chunk in iter {
172+
res.push_str(chunk.valid());
173+
let invalid = chunk.invalid();
174+
if !invalid.is_empty() {
175+
for byte in invalid {
176+
res.push_str(b2pc(*byte));
177+
}
178+
}
144179
}
180+
Cow::Owned(res)
145181
}
146182

147183
#[cfg(test)]
148184
mod tests {
149185
use crate::{
150-
macos::{DeleteMethod, TrashContextExtMacos},
186+
macos::{percent_encode, DeleteMethod, TrashContextExtMacos},
151187
tests::{get_unique_name, init_logging},
152188
TrashContext,
153189
};
154190
use serial_test::serial;
191+
use std::ffi::OsStr;
155192
use std::fs::File;
193+
use std::os::unix::ffi::OsStrExt;
194+
use std::path::PathBuf;
195+
use std::process::Command;
156196

157197
#[test]
158198
#[serial]
@@ -166,4 +206,69 @@ mod tests {
166206
trash_ctx.delete(&path).unwrap();
167207
assert!(File::open(&path).is_err());
168208
}
209+
210+
#[test]
211+
#[serial]
212+
fn test_delete_binary_path_with_ns_file_manager() {
213+
let (_cleanup, tmp) = create_hfs_volume().unwrap();
214+
let parent_fs_supports_binary = tmp.path();
215+
216+
init_logging();
217+
let mut trash_ctx = TrashContext::default();
218+
trash_ctx.set_delete_method(DeleteMethod::NsFileManager);
219+
220+
let invalid_utf8 = b"\x80"; // lone continuation byte (128) (invalid utf8)
221+
let mut path_invalid = parent_fs_supports_binary.join(get_unique_name());
222+
path_invalid.set_extension(OsStr::from_bytes(invalid_utf8)); //...trash-test-111-0.\x80 (not push to avoid fail unexisting dir)
223+
224+
File::create_new(&path_invalid).unwrap();
225+
226+
assert!(path_invalid.exists());
227+
trash_ctx.delete(&path_invalid).unwrap();
228+
assert!(!path_invalid.exists());
229+
}
230+
231+
#[test]
232+
fn test_path_byte() {
233+
let invalid_utf8 = b"\x80"; // lone continuation byte (128) (invalid utf8)
234+
let percent_encoded = "%80"; // valid macOS path in a %-escaped encoding
235+
236+
let mut expected_path = PathBuf::from(get_unique_name());
237+
let mut path_with_invalid_utf8 = expected_path.clone();
238+
239+
path_with_invalid_utf8.push(OsStr::from_bytes(invalid_utf8)); // trash-test-111-0/\x80
240+
expected_path.push(percent_encoded); // trash-test-111-0/%80
241+
242+
let actual = percent_encode(&path_with_invalid_utf8.as_os_str().as_encoded_bytes()); // trash-test-111-0/%80
243+
assert_eq!(std::path::Path::new(actual.as_ref()), expected_path);
244+
}
245+
246+
fn create_hfs_volume() -> std::io::Result<(impl Drop, tempfile::TempDir)> {
247+
let tmp = tempfile::tempdir()?;
248+
let dmg_file = tmp.path().join("fs.dmg");
249+
let cleanup = {
250+
// Create dmg file
251+
Command::new("hdiutil").args(["create", "-size", "1m", "-fs", "HFS+"]).arg(&dmg_file).status()?;
252+
253+
// Mount dmg file into temporary location
254+
Command::new("hdiutil")
255+
.args(["attach", "-nobrowse", "-mountpoint"])
256+
.arg(tmp.path())
257+
.arg(&dmg_file)
258+
.status()?;
259+
260+
// Ensure that the mount point is always cleaned up
261+
defer::defer({
262+
let mount_point = tmp.path().to_owned();
263+
move || {
264+
Command::new("hdiutil")
265+
.arg("detach")
266+
.arg(&mount_point)
267+
.status()
268+
.expect("detach temporary test dmg filesystem successfully");
269+
}
270+
})
271+
};
272+
Ok((cleanup, tmp))
273+
}
169274
}

tests/trash.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
use std::ffi::OsStr;
21
use std::fs::{create_dir, File};
32
use std::path::{Path, PathBuf};
43

@@ -143,6 +142,7 @@ fn create_remove_single_file() {
143142
#[test]
144143
#[serial]
145144
fn create_remove_single_file_invalid_utf8() {
145+
use std::ffi::OsStr;
146146
let name = unsafe { OsStr::from_encoded_bytes_unchecked(&[168]) };
147147
File::create_new(name).unwrap();
148148
trash::delete(name).unwrap();

0 commit comments

Comments
 (0)