From 72026dc1448bce720a6b4fa7e03312d4c4f70431 Mon Sep 17 00:00:00 2001 From: Hongcai Deng Date: Sat, 27 Sep 2025 01:31:43 +0000 Subject: [PATCH] feat: Add support for symlink extraction This change adds support for restoring symbolic links when unzipping archives. The implementation is platform-specific and will only restore symlinks on Unix-like systems. This is achieved by using conditional compilation (`#[cfg(unix)]`). --- src/unzip/mod.rs | 158 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 114 insertions(+), 44 deletions(-) diff --git a/src/unzip/mod.rs b/src/unzip/mod.rs index 4e46457..32c48fb 100644 --- a/src/unzip/mod.rs +++ b/src/unzip/mod.rs @@ -430,8 +430,8 @@ fn extract_file_inner( .ok_or_else(|| std::io::Error::new(ErrorKind::Unsupported, "path not safe to extract"))?; let display_name = name.display().to_string(); let out_path = match output_directory { - Some(output_directory) => output_directory.join(name), - None => name, + Some(output_directory) => output_directory.join(&*name), + None => name.to_path_buf(), }; progress_reporter.extraction_starting(&display_name); log::debug!( @@ -446,55 +446,90 @@ fn extract_file_inner( if let Some(parent) = out_path.parent() { directory_creator.create_dir_all(parent)?; } - let out_file = File::create(&out_path).map_err(|e| RipunzipErrors::IOErrorWithContext { - context: format!("Failed to create file {}", out_path.display()), - source: e, - })?; - // Progress bar strategy. The overall progress across the entire zip file must be - // denoted in terms of *compressed* bytes, since at the outset we don't know the uncompressed - // size of each file. Yet, within a given file, we update progress based on the bytes - // of uncompressed data written, once per 1MB, because that's the information that we happen - // to have available. So, calculate how many compressed bytes relate to 1MB of uncompressed - // data, and the remainder. - let uncompressed_size = file.size(); - let compressed_size = file.compressed_size(); - let mut progress_updater = ProgressUpdater::new( - |external_progress| { - progress_reporter.bytes_extracted(external_progress); - }, - compressed_size, - uncompressed_size, - 1024 * 1024, - ); - let mut out_file = progress_streams::ProgressWriter::new(out_file, |bytes_written| { - progress_updater.progress(bytes_written as u64) - }); - // Using a BufWriter here doesn't improve performance even on a VM with - // spinny disks. - if let Err(e) = std::io::copy(&mut file, &mut out_file) { - return Err(RipunzipErrors::IOErrorWithContext { - context: format!("Failed to write directory {:?}", out_file.into_inner()), - source: e, + let is_symlink = { + #[cfg(unix)] + { + file.is_symlink() + } + #[cfg(not(unix))] + { + false + } + }; + if is_symlink { + #[cfg(unix)] + { + let mut target = String::new(); + file.read_to_string(&mut target) + .map_err(|e| RipunzipErrors::IOErrorWithContext { + context: format!("Failed to read symlink target for {}", out_path.display()), + source: e, + })?; + if let Err(e) = std::os::unix::fs::symlink(&target, &out_path) { + return Err(RipunzipErrors::IOErrorWithContext { + context: format!( + "Failed to create symlink {} -> {}", + out_path.display(), + target + ), + source: e, + }); + } + } + } else { + let out_file = + File::create(&out_path).map_err(|e| RipunzipErrors::IOErrorWithContext { + context: format!("Failed to create file {}", out_path.display()), + source: e, + })?; + // Progress bar strategy. The overall progress across the entire zip file must be + // denoted in terms of *compressed* bytes, since at the outset we don't know the uncompressed + // size of each file. Yet, within a given file, we update progress based on the bytes + // of uncompressed data written, once per 1MB, because that's the information that we happen + // to have available. So, calculate how many compressed bytes relate to 1MB of uncompressed + // data, and the remainder. + let uncompressed_size = file.size(); + let compressed_size = file.compressed_size(); + let mut progress_updater = ProgressUpdater::new( + |external_progress| { + progress_reporter.bytes_extracted(external_progress); + }, + compressed_size, + uncompressed_size, + 1024 * 1024, + ); + let mut out_file = progress_streams::ProgressWriter::new(out_file, |bytes_written| { + progress_updater.progress(bytes_written as u64) }); + // Using a BufWriter here doesn't improve performance even on a VM with + // spinny disks. + if let Err(e) = std::io::copy(&mut file, &mut out_file) { + return Err(RipunzipErrors::IOErrorWithContext { + context: format!("Failed to write directory {:?}", out_file.into_inner()), + source: e, + }); + } + progress_updater.finish(); } - progress_updater.finish(); } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - if let Some(mode) = file.unix_mode() { - if let Err(e) = - std::fs::set_permissions(&out_path, std::fs::Permissions::from_mode(mode)) - { - return Err(RipunzipErrors::IOErrorWithContext { - context: format!( - "Failed to set permissions {} for {}", - mode, - out_path.display() - ), - source: e, - }); + if !file.is_symlink() { + if let Some(mode) = file.unix_mode() { + if let Err(e) = + std::fs::set_permissions(&out_path, std::fs::Permissions::from_mode(mode)) + { + return Err(RipunzipErrors::IOErrorWithContext { + context: format!( + "Failed to set permissions {} for {}", + mode, + out_path.display() + ), + source: e, + }); + } } } } @@ -622,6 +657,41 @@ mod tests { assert_eq!(read_to_string(c).unwrap(), "Contents of C\n"); } + #[test] + #[cfg(unix)] + fn test_extract_symlink() { + let td = tempdir().unwrap(); + let zf = td.path().join("z.zip"); + let file = File::create(&zf).unwrap(); + let mut zip = ZipWriter::new(file); + let options: FileOptions = FileOptions::default() + .compression_method(zip::CompressionMethod::Stored) + .unix_permissions(0o755); + zip.add_directory::<_, ExtendedFileOptions>("test/", Default::default()) + .unwrap(); + zip.start_file("test/a.txt", options.clone()).unwrap(); + zip.write_all(b"Contents of A\n").unwrap(); + zip.add_symlink("test/b.txt", "a.txt", options.clone()) + .unwrap(); + zip.finish().unwrap(); + + let zf = File::open(zf).unwrap(); + let outdir = td.path().join("outdir"); + let options = UnzipOptions { + output_directory: Some(outdir.clone()), + password: None, + single_threaded: false, + filename_filter: None, + progress_reporter: Box::new(NullProgressReporter), + }; + UnzipEngine::for_file(zf).unwrap().unzip(options).unwrap(); + + let a = outdir.join("test/a.txt"); + let b = outdir.join("test/b.txt"); + assert_eq!(read_to_string(a).unwrap(), "Contents of A\n"); + assert_eq!(read_to_string(b).unwrap(), "Contents of A\n"); + } + #[test] #[ignore] // because the chdir changes global state fn test_extract_no_path() {