1- use std:: { ffi:: OsString , path:: PathBuf , process:: Command } ;
1+ use std:: {
2+ ffi:: OsString ,
3+ path:: { Path , PathBuf } ,
4+ process:: Command ,
5+ } ;
26
37use log:: trace;
48use objc2_foundation:: { NSFileManager , NSString , NSURL } ;
@@ -71,22 +75,25 @@ impl TrashContextExtMacos for TrashContext {
7175}
7276impl 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) ]
148184mod 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}
0 commit comments