Skip to content

Commit b3e2493

Browse files
committed
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)]`).
1 parent 08fade5 commit b3e2493

1 file changed

Lines changed: 114 additions & 44 deletions

File tree

src/unzip/mod.rs

Lines changed: 114 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -421,8 +421,8 @@ fn extract_file_inner<R: Read>(
421421
.ok_or_else(|| std::io::Error::new(ErrorKind::Unsupported, "path not safe to extract"))?;
422422
let display_name = name.display().to_string();
423423
let out_path = match output_directory {
424-
Some(output_directory) => output_directory.join(name),
425-
None => name,
424+
Some(output_directory) => output_directory.join(&*name),
425+
None => name.to_path_buf(),
426426
};
427427
progress_reporter.extraction_starting(&display_name);
428428
log::debug!(
@@ -437,55 +437,90 @@ fn extract_file_inner<R: Read>(
437437
if let Some(parent) = out_path.parent() {
438438
directory_creator.create_dir_all(parent)?;
439439
}
440-
let out_file = File::create(&out_path).map_err(|e| RipunzipErrors::IOErrorWithContext {
441-
context: format!("Failed to create file {}", out_path.display()),
442-
source: e,
443-
})?;
444-
// Progress bar strategy. The overall progress across the entire zip file must be
445-
// denoted in terms of *compressed* bytes, since at the outset we don't know the uncompressed
446-
// size of each file. Yet, within a given file, we update progress based on the bytes
447-
// of uncompressed data written, once per 1MB, because that's the information that we happen
448-
// to have available. So, calculate how many compressed bytes relate to 1MB of uncompressed
449-
// data, and the remainder.
450-
let uncompressed_size = file.size();
451-
let compressed_size = file.compressed_size();
452-
let mut progress_updater = ProgressUpdater::new(
453-
|external_progress| {
454-
progress_reporter.bytes_extracted(external_progress);
455-
},
456-
compressed_size,
457-
uncompressed_size,
458-
1024 * 1024,
459-
);
460-
let mut out_file = progress_streams::ProgressWriter::new(out_file, |bytes_written| {
461-
progress_updater.progress(bytes_written as u64)
462-
});
463-
// Using a BufWriter here doesn't improve performance even on a VM with
464-
// spinny disks.
465-
if let Err(e) = std::io::copy(&mut file, &mut out_file) {
466-
return Err(RipunzipErrors::IOErrorWithContext {
467-
context: format!("Failed to write directory {:?}", out_file.into_inner()),
468-
source: e,
440+
let is_symlink = {
441+
#[cfg(unix)]
442+
{
443+
file.is_symlink()
444+
}
445+
#[cfg(not(unix))]
446+
{
447+
false
448+
}
449+
};
450+
if is_symlink {
451+
#[cfg(unix)]
452+
{
453+
let mut target = String::new();
454+
file.read_to_string(&mut target)
455+
.map_err(|e| RipunzipErrors::IOErrorWithContext {
456+
context: format!("Failed to read symlink target for {}", out_path.display()),
457+
source: e,
458+
})?;
459+
if let Err(e) = std::os::unix::fs::symlink(&target, &out_path) {
460+
return Err(RipunzipErrors::IOErrorWithContext {
461+
context: format!(
462+
"Failed to create symlink {} -> {}",
463+
out_path.display(),
464+
target
465+
),
466+
source: e,
467+
});
468+
}
469+
}
470+
} else {
471+
let out_file =
472+
File::create(&out_path).map_err(|e| RipunzipErrors::IOErrorWithContext {
473+
context: format!("Failed to create file {}", out_path.display()),
474+
source: e,
475+
})?;
476+
// Progress bar strategy. The overall progress across the entire zip file must be
477+
// denoted in terms of *compressed* bytes, since at the outset we don't know the uncompressed
478+
// size of each file. Yet, within a given file, we update progress based on the bytes
479+
// of uncompressed data written, once per 1MB, because that's the information that we happen
480+
// to have available. So, calculate how many compressed bytes relate to 1MB of uncompressed
481+
// data, and the remainder.
482+
let uncompressed_size = file.size();
483+
let compressed_size = file.compressed_size();
484+
let mut progress_updater = ProgressUpdater::new(
485+
|external_progress| {
486+
progress_reporter.bytes_extracted(external_progress);
487+
},
488+
compressed_size,
489+
uncompressed_size,
490+
1024 * 1024,
491+
);
492+
let mut out_file = progress_streams::ProgressWriter::new(out_file, |bytes_written| {
493+
progress_updater.progress(bytes_written as u64)
469494
});
495+
// Using a BufWriter here doesn't improve performance even on a VM with
496+
// spinny disks.
497+
if let Err(e) = std::io::copy(&mut file, &mut out_file) {
498+
return Err(RipunzipErrors::IOErrorWithContext {
499+
context: format!("Failed to write directory {:?}", out_file.into_inner()),
500+
source: e,
501+
});
502+
}
503+
progress_updater.finish();
470504
}
471-
progress_updater.finish();
472505
}
473506

474507
#[cfg(unix)]
475508
{
476509
use std::os::unix::fs::PermissionsExt;
477-
if let Some(mode) = file.unix_mode() {
478-
if let Err(e) =
479-
std::fs::set_permissions(&out_path, std::fs::Permissions::from_mode(mode))
480-
{
481-
return Err(RipunzipErrors::IOErrorWithContext {
482-
context: format!(
483-
"Failed to set permissions {} for {}",
484-
mode,
485-
out_path.display()
486-
),
487-
source: e,
488-
});
510+
if !file.is_symlink() {
511+
if let Some(mode) = file.unix_mode() {
512+
if let Err(e) =
513+
std::fs::set_permissions(&out_path, std::fs::Permissions::from_mode(mode))
514+
{
515+
return Err(RipunzipErrors::IOErrorWithContext {
516+
context: format!(
517+
"Failed to set permissions {} for {}",
518+
mode,
519+
out_path.display()
520+
),
521+
source: e,
522+
});
523+
}
489524
}
490525
}
491526
}
@@ -613,6 +648,41 @@ mod tests {
613648
assert_eq!(read_to_string(c).unwrap(), "Contents of C\n");
614649
}
615650

651+
#[test]
652+
#[cfg(unix)]
653+
fn test_extract_symlink() {
654+
let td = tempdir().unwrap();
655+
let zf = td.path().join("z.zip");
656+
let file = File::create(&zf).unwrap();
657+
let mut zip = ZipWriter::new(file);
658+
let options: FileOptions<ExtendedFileOptions> = FileOptions::default()
659+
.compression_method(zip::CompressionMethod::Stored)
660+
.unix_permissions(0o755);
661+
zip.add_directory::<_, ExtendedFileOptions>("test/", Default::default())
662+
.unwrap();
663+
zip.start_file("test/a.txt", options.clone()).unwrap();
664+
zip.write_all(b"Contents of A\n").unwrap();
665+
zip.add_symlink("test/b.txt", "a.txt", options.clone())
666+
.unwrap();
667+
zip.finish().unwrap();
668+
669+
let zf = File::open(zf).unwrap();
670+
let outdir = td.path().join("outdir");
671+
let options = UnzipOptions {
672+
output_directory: Some(outdir.clone()),
673+
password: None,
674+
single_threaded: false,
675+
filename_filter: None,
676+
progress_reporter: Box::new(NullProgressReporter),
677+
};
678+
UnzipEngine::for_file(zf).unwrap().unzip(options).unwrap();
679+
680+
let a = outdir.join("test/a.txt");
681+
let b = outdir.join("test/b.txt");
682+
assert_eq!(read_to_string(a).unwrap(), "Contents of A\n");
683+
assert_eq!(read_to_string(b).unwrap(), "Contents of A\n");
684+
}
685+
616686
#[test]
617687
#[ignore] // because the chdir changes global state
618688
fn test_extract_no_path() {

0 commit comments

Comments
 (0)