Skip to content

Commit f1b67da

Browse files
authored
make dir flattening not use CWD when decompressing (#996)
in preparation for sandboxing
1 parent 4cabe4c commit f1b67da

1 file changed

Lines changed: 61 additions & 32 deletions

File tree

src/commands/decompress.rs

Lines changed: 61 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -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,
283281
fn 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

Comments
 (0)