@@ -278,8 +278,6 @@ fn unpack_archive(
278278/// Expects `wrapper` to be a just-decompressed archive output directory, if
279279/// `wrapper` contains exactly one entry with the same name (e.g. extracting
280280/// `archive.zip` produced `archive/archive/...`), then flatten it to `archive/...`.
281- ///
282- /// Returns the resulting path the user should see. If reads fail,
283281fn deduplicate_basename_wrapper ( wrapper : & Path ) -> Result < ( ) > {
284282 let Some ( wrapper_name) = wrapper. file_name ( ) else {
285283 return Ok ( ( ) ) ;
@@ -303,33 +301,37 @@ fn deduplicate_basename_wrapper(wrapper: &Path) -> Result<()> {
303301 return Ok ( ( ) ) ;
304302 }
305303
306- // The wrapper duplicates the inner entry's name. Promote the inner entry by:
307- // 1. moving it into a sibling temporary directory
308- // 2. removing the now-empty wrapper
309- // 3. moving it back to the wrapper's path
310- // Each step leaves the filesystem in a consistent state, and a failure midway
311- // leaves the user with valid extracted content (just nested one level).
312- let inner_path = only_file_in_dir. path ( ) ;
313- let Some ( parent) = wrapper. parent ( ) else {
304+ // Only collapse when the inner duplicate is itself a directory.
305+ if !only_file_in_dir. file_type ( ) ?. is_dir ( ) {
314306 return Ok ( ( ) ) ;
315- } ;
316-
317- // Create the sibling
318- let sibling_path = parent. join ( "ouch-temporary" ) ;
319- fs:: create_dir ( & sibling_path) ?;
320-
321- // Move child inside the sibling
322- let path_inside_sibling = sibling_path. join ( wrapper_name) ;
323- fs:: rename ( & inner_path, & path_inside_sibling) ?;
324-
325- // Delete old parent
326- fs:: remove_dir ( wrapper) ?;
307+ }
327308
328- // Move child to its dead parent place
329- fs:: rename ( & path_inside_sibling, wrapper) ?;
309+ // Promote inner entries one level up, all writes stay inside `wrapper`.
310+ let inner_dir = only_file_in_dir. path ( ) ;
311+
312+ let mut staged: Option < tempfile:: TempPath > = None ;
313+ for entry in fs:: read_dir ( & inner_dir) ? {
314+ let entry = entry?;
315+ let name = entry. file_name ( ) ;
316+ if name == wrapper_name {
317+ // Stage under a random name to avoid colliding with the still-existing inner dir.
318+ let tmp = tempfile:: Builder :: new ( )
319+ . prefix ( ".ouch-" )
320+ . tempfile_in ( wrapper) ?
321+ . into_temp_path ( ) ;
322+ fs:: remove_file ( & tmp) ?;
323+ fs:: rename ( entry. path ( ) , & tmp) ?;
324+ staged = Some ( tmp) ;
325+ } else {
326+ fs:: rename ( entry. path ( ) , wrapper. join ( name) ) ?;
327+ }
328+ }
330329
331- // Delete the temporary sibling
332- fs:: remove_dir ( sibling_path) ?;
330+ fs:: remove_dir ( & inner_dir) ?;
331+ if let Some ( tmp) = staged {
332+ fs:: rename ( & tmp, wrapper. join ( wrapper_name) ) ?;
333+ let _ = tmp. keep ( ) ;
334+ }
333335
334336 Ok ( ( ) )
335337}
@@ -365,7 +367,8 @@ mod tests {
365367 }
366368
367369 /// The main case: wrapper contains exactly one entry whose name equals the wrapper's
368- /// name. The inner entry should be promoted up one level.
370+ /// name. The inner entry should be promoted up one level. The flatten must not
371+ /// create or touch anything in the wrapper's parent directory.
369372 #[ test]
370373 fn deduplicate_flattens_when_inner_dir_matches_wrapper_name ( ) {
371374 let dir = tempdir ( ) . unwrap ( ) ;
@@ -377,6 +380,12 @@ mod tests {
377380
378381 deduplicate_basename_wrapper ( & wrapper) . unwrap ( ) ;
379382 assert_eq ! ( list_tree( & wrapper) , vec![ "a.txt" , "b.txt" ] ) ;
383+ // Parent must be untouched: only `wrapper` itself should be visible there.
384+ let parent_entries: Vec < _ > = std_fs:: read_dir ( dir. path ( ) )
385+ . unwrap ( )
386+ . map ( |e| e. unwrap ( ) . file_name ( ) )
387+ . collect ( ) ;
388+ assert_eq ! ( parent_entries, vec![ std:: ffi:: OsString :: from( "archive" ) ] ) ;
380389 }
381390
382391 /// Wrapper contains a single entry, but its name differs from the wrapper's name.
@@ -418,18 +427,38 @@ mod tests {
418427 assert_eq ! ( list_tree( & wrapper) , Vec :: <String >:: new( ) ) ;
419428 }
420429
421- /// Edge case: the single inner entry is a * file* (not a directory) whose name
422- /// equals the wrapper's name. The wrapper directory should be replaced with that file .
430+ /// Wrapper contains a single entry, but it's a file (not a directory).
431+ /// No flatten should happen — the wrapper survives and the file stays inside .
423432 #[ test]
424- fn deduplicate_promotes_single_inner_file_with_matching_name ( ) {
433+ fn deduplicate_keeps_wrapper_when_inner_is_file ( ) {
425434 let dir = tempdir ( ) . unwrap ( ) ;
426435 let wrapper = dir. path ( ) . join ( "archive" ) ;
427436 std_fs:: create_dir ( & wrapper) . unwrap ( ) ;
428437 std_fs:: write ( wrapper. join ( "archive" ) , "data" ) . unwrap ( ) ;
429438
430439 deduplicate_basename_wrapper ( & wrapper) . unwrap ( ) ;
431- assert ! ( wrapper. is_file( ) , "wrapper should now be a file, not a directory" ) ;
432- assert_eq ! ( std_fs:: read( & wrapper) . unwrap( ) , b"data" ) ;
440+ assert ! ( wrapper. is_dir( ) , "wrapper must remain a directory" ) ;
441+ assert_eq ! ( list_tree( & wrapper) , vec![ "archive" . to_string( ) ] ) ;
442+ assert_eq ! ( std_fs:: read( wrapper. join( "archive" ) ) . unwrap( ) , b"data" ) ;
443+ }
444+
445+ /// Inner dir's children include one that shares the wrapper's name. The
446+ /// in-place flatten must stage it under a random name to avoid the collision
447+ /// and end with `wrapper/archive` containing the collision's contents.
448+ #[ test]
449+ fn deduplicate_handles_inner_entry_named_like_wrapper ( ) {
450+ let dir = tempdir ( ) . unwrap ( ) ;
451+ let wrapper = dir. path ( ) . join ( "archive" ) ;
452+ let inner = wrapper. join ( "archive" ) ;
453+ let collision = inner. join ( "archive" ) ;
454+ std_fs:: create_dir_all ( & collision) . unwrap ( ) ;
455+ std_fs:: write ( collision. join ( "inside" ) , "x" ) . unwrap ( ) ;
456+ std_fs:: write ( inner. join ( "other.txt" ) , "o" ) . unwrap ( ) ;
457+
458+ deduplicate_basename_wrapper ( & wrapper) . unwrap ( ) ;
459+ assert_eq ! ( list_tree( & wrapper) , vec![ "archive/" , "archive/inside" , "other.txt" ] ) ;
460+ assert_eq ! ( std_fs:: read( wrapper. join( "archive" ) . join( "inside" ) ) . unwrap( ) , b"x" ) ;
461+ assert_eq ! ( std_fs:: read( wrapper. join( "other.txt" ) ) . unwrap( ) , b"o" ) ;
433462 }
434463
435464 /// The flatten only collapses *one* level: nested same-name directories produced
0 commit comments