diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index e3634e1..ae7f165 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -75,6 +75,7 @@ jobs: run: | sudo apt-get update sudo apt-get install musl-tools + sudo ln -s "/usr/bin/g++" "/usr/bin/musl-g++" - name: Set up extra cargo flags if: matrix.no-zstd-thin diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bfa79e..edfb989 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ Categories Used: **Bullet points in chronological order by PR** ## [Unreleased](https://github.com/ouch-org/ouch/compare/0.4.2...HEAD) +- Add support for listing and decompressing `.rar` archives +- Fix mime type detection ### Bug Fixes diff --git a/Cargo.lock b/Cargo.lock index 8f3e968..489435b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -763,6 +763,7 @@ dependencies = [ "tempfile", "test-strategy", "time", + "unrar", "xz2", "zip", "zstd", @@ -1253,6 +1254,29 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c" +[[package]] +name = "unrar" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "850dc99da3a3c12a9400a52a3c510057bf2b16c6c9a495492850d25ed9d757ab" +dependencies = [ + "bitflags 1.3.2", + "regex", + "unrar_sys", + "widestring", +] + +[[package]] +name = "unrar_sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95e536227cb6a91f8e88adb0219912e004a107c9724a094f90baad9229281efa" +dependencies = [ + "cc", + "libc", + "winapi", +] + [[package]] name = "utf8parse" version = "0.2.1" @@ -1350,6 +1374,12 @@ version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1" +[[package]] +name = "widestring" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "653f141f39ec16bba3c5abe400a0c60da7468261cc2cbf36805022876bc721a8" + [[package]] name = "winapi" version = "0.3.9" diff --git a/src/archive/mod.rs b/src/archive/mod.rs index e02b432..428f3cb 100644 --- a/src/archive/mod.rs +++ b/src/archive/mod.rs @@ -1,4 +1,5 @@ //! Archive compression algorithms +pub mod rar; pub mod tar; pub mod zip; diff --git a/src/archive/rar.rs b/src/archive/rar.rs new file mode 100644 index 0000000..c08ec58 --- /dev/null +++ b/src/archive/rar.rs @@ -0,0 +1,56 @@ +//! Contains RAR-specific building and unpacking functions + +use std::path::Path; + +use unrar::{self, Archive}; + +use crate::{info, list::FileInArchive, warning}; + +/// Unpacks the archive given by `archive_path` into the folder given by `output_folder`. +/// Assumes that output_folder is empty +pub fn unpack_archive(archive_path: &Path, output_folder: &Path, quiet: bool) -> crate::Result { + assert!(output_folder.read_dir().expect("dir exists").count() == 0); + + let mut archive = Archive::new(archive_path).open_for_processing()?; + let mut unpacked = 0; + + while let Some(header) = archive.read_header()? { + let entry = header.entry(); + archive = if entry.is_file() { + if !quiet { + info!( + inaccessible, + "{} extracted. ({})", + entry.filename.display(), + entry.unpacked_size + ); + } + unpacked += 1; + header.extract_with_base(output_folder)? + } else { + header.skip()? + }; + } + + Ok(unpacked) +} + +/// List contents of `archive_path`, returning a vector of archive entries +pub fn list_archive(archive_path: &Path) -> impl Iterator> { + Archive::new(archive_path) + .open_for_listing() + .expect("cannot open archive") + .map(|item| { + let item = item?; + let is_dir = item.is_directory(); + let path = item.filename; + + Ok(FileInArchive { path, is_dir }) + }) +} + +pub fn no_compression_notice() { + const MESSAGE: &str = "Creating '.rar' archives is not supported due to licensing restrictions"; + + warning!("{}", MESSAGE); +} diff --git a/src/commands/compress.rs b/src/commands/compress.rs index f65523b..72e92c4 100644 --- a/src/commands/compress.rs +++ b/src/commands/compress.rs @@ -79,7 +79,7 @@ pub fn compress_files( // is `clamp`ed and therefore guaranteed to be valid Box::new(zstd_encoder.unwrap().auto_finish()) } - Tar | Zip => unreachable!(), + Tar | Zip | Rar => unreachable!(), }; Ok(encoder) }; @@ -122,6 +122,10 @@ pub fn compress_files( vec_buffer.rewind()?; io::copy(&mut vec_buffer, &mut writer)?; } + Rar => { + archive::rar::no_compression_notice(); + return Ok(false); + } } Ok(true) diff --git a/src/commands/decompress.rs b/src/commands/decompress.rs index 6683201..9ae0ade 100644 --- a/src/commands/decompress.rs +++ b/src/commands/decompress.rs @@ -86,7 +86,7 @@ pub fn decompress_file( Lzma => Box::new(xz2::read::XzDecoder::new(decoder)), Snappy => Box::new(snap::read::FrameDecoder::new(decoder)), Zstd => Box::new(zstd::stream::Decoder::new(decoder)?), - Tar | Zip => unreachable!(), + Tar | Zip | Rar => unreachable!(), }; Ok(decoder) }; @@ -146,6 +146,24 @@ pub fn decompress_file( return Ok(()); } } + Rar => { + type UnpackResult = crate::Result; + let unpack_fn: Box UnpackResult> = if formats.len() > 1 { + let mut temp_file = tempfile::NamedTempFile::new()?; + io::copy(&mut reader, &mut temp_file)?; + Box::new(move |output_dir| crate::archive::rar::unpack_archive(temp_file.path(), output_dir, quiet)) + } else { + Box::new(|output_dir| crate::archive::rar::unpack_archive(input_file_path, output_dir, quiet)) + }; + + if let ControlFlow::Continue(files) = + smart_unpack(unpack_fn, output_dir, &output_file_path, question_policy)? + { + files + } else { + return Ok(()); + } + } }; // this is only printed once, so it doesn't result in much text. On the other hand, diff --git a/src/commands/list.rs b/src/commands/list.rs index 5a478d8..e33d999 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -52,7 +52,7 @@ pub fn list_archive_contents( Lzma => Box::new(xz2::read::XzDecoder::new(decoder)), Snappy => Box::new(snap::read::FrameDecoder::new(decoder)), Zstd => Box::new(zstd::stream::Decoder::new(decoder)?), - Tar | Zip => unreachable!(), + Tar | Zip | Rar => unreachable!(), }; Ok(decoder) }; @@ -78,6 +78,15 @@ pub fn list_archive_contents( Box::new(crate::archive::zip::list_archive(zip_archive)) } + Rar => { + if formats.len() > 1 { + let mut temp_file = tempfile::NamedTempFile::new()?; + io::copy(&mut reader, &mut temp_file)?; + Box::new(crate::archive::rar::list_archive(temp_file.path())) + } else { + Box::new(crate::archive::rar::list_archive(archive_path)) + } + } Gzip | Bzip | Lz4 | Lzma | Snappy | Zstd => { panic!("Not an archive! This should never happen, if it does, something is wrong with `CompressionFormat::is_archive()`. Please report this error!"); } diff --git a/src/error.rs b/src/error.rs index a8832ea..896f383 100644 --- a/src/error.rs +++ b/src/error.rs @@ -178,6 +178,14 @@ impl From for Error { } } +impl From for Error { + fn from(err: unrar::error::UnrarError) -> Self { + Self::Custom { + reason: FinalError::with_title("Unexpected error in rar archive").detail(format!("{:?}", err.code)), + } + } +} + impl From for Error { fn from(err: ignore::Error) -> Self { Self::WalkdirError { diff --git a/src/extension.rs b/src/extension.rs index ac79303..85451cb 100644 --- a/src/extension.rs +++ b/src/extension.rs @@ -7,9 +7,9 @@ use bstr::ByteSlice; use self::CompressionFormat::*; use crate::{error::Error, warning}; -pub const SUPPORTED_EXTENSIONS: &[&str] = &["tar", "zip", "bz", "bz2", "gz", "lz4", "xz", "lzma", "sz", "zst"]; +pub const SUPPORTED_EXTENSIONS: &[&str] = &["tar", "zip", "bz", "bz2", "gz", "lz4", "xz", "lzma", "sz", "zst", "rar"]; pub const SUPPORTED_ALIASES: &[&str] = &["tgz", "tbz", "tlz4", "txz", "tzlma", "tsz", "tzst"]; -pub const PRETTY_SUPPORTED_EXTENSIONS: &str = "tar, zip, bz, bz2, gz, lz4, xz, lzma, sz, zst"; +pub const PRETTY_SUPPORTED_EXTENSIONS: &str = "tar, zip, bz, bz2, gz, lz4, xz, lzma, sz, zst, rar"; pub const PRETTY_SUPPORTED_ALIASES: &str = "tgz, tbz, tlz4, txz, tzlma, tsz, tzst"; /// A wrapper around `CompressionFormat` that allows combinations like `tgz` @@ -72,6 +72,8 @@ pub enum CompressionFormat { Zstd, /// .zip Zip, + /// .rar + Rar, } impl CompressionFormat { @@ -79,7 +81,7 @@ impl CompressionFormat { fn is_archive_format(&self) -> bool { // Keep this match like that without a wildcard `_` so we don't forget to update it match self { - Tar | Zip => true, + Tar | Zip | Rar => true, Gzip => false, Bzip => false, Lz4 => false, @@ -107,6 +109,7 @@ fn to_extension(ext: &[u8]) -> Option { b"xz" | b"lzma" => &[Lzma], b"sz" => &[Snappy], b"zst" => &[Zstd], + b"rar" => &[Rar], _ => return None, }, ext.to_str_lossy(), diff --git a/src/utils/fs.rs b/src/utils/fs.rs index f6f4888..08f6d1b 100644 --- a/src/utils/fs.rs +++ b/src/utils/fs.rs @@ -86,6 +86,11 @@ pub fn try_infer_extension(path: &Path) -> Option { fn is_zst(buf: &[u8]) -> bool { buf.starts_with(&[0x28, 0xB5, 0x2F, 0xFD]) } + fn is_rar(buf: &[u8]) -> bool { + buf.len() >= 7 + && buf.starts_with(&[0x52, 0x61, 0x72, 0x21, 0x1A, 0x07]) + && (buf[6] == 0x00 || (buf.len() >= 8 && buf[6..=7] == [0x01, 0x00])) + } let buf = { let mut buf = [0; 270]; @@ -117,6 +122,8 @@ pub fn try_infer_extension(path: &Path) -> Option { Some(Extension::new(&[Snappy], "sz")) } else if is_zst(&buf) { Some(Extension::new(&[Zstd], "zst")) + } else if is_rar(&buf) { + Some(Extension::new(&[Rar], "rar")) } else { None } diff --git a/tests/data/testfile.rar3.rar.gz b/tests/data/testfile.rar3.rar.gz new file mode 100644 index 0000000..d6771d0 Binary files /dev/null and b/tests/data/testfile.rar3.rar.gz differ diff --git a/tests/data/testfile.rar5.rar b/tests/data/testfile.rar5.rar new file mode 100644 index 0000000..c6bfec6 Binary files /dev/null and b/tests/data/testfile.rar5.rar differ diff --git a/tests/integration.rs b/tests/integration.rs index bd968d9..b67b3ee 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,7 +1,10 @@ #[macro_use] mod utils; -use std::{iter::once, path::PathBuf}; +use std::{ + iter::once, + path::{Path, PathBuf}, +}; use fs_err as fs; use parse_display::Display; @@ -144,3 +147,26 @@ fn multiple_files( ouch!("-A", "d", archive, "-d", after); assert_same_directory(before, after, !matches!(ext, DirectoryExtension::Zip)); } + +// test .rar decompression +fn test_unpack_rar_single(input: &Path) -> Result<(), Box> { + let dir = tempdir()?; + let dirpath = dir.path(); + let unpacked_path = &dirpath.join("testfile.txt"); + ouch!("-A", "d", input, "-d", dirpath); + let content = fs::read_to_string(unpacked_path)?; + assert_eq!(content, "Testing 123\n"); + + Ok(()) +} + +#[test] +fn unpack_rar() -> Result<(), Box> { + let mut datadir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?); + datadir.push("tests/data"); + ["testfile.rar3.rar.gz", "testfile.rar5.rar"] + .iter() + .try_for_each(|path| test_unpack_rar_single(&datadir.join(path)))?; + + Ok(()) +} diff --git a/tests/snapshots/ui__ui_test_err_decompress_missing_extension-2.snap b/tests/snapshots/ui__ui_test_err_decompress_missing_extension-2.snap index 5f5cddb..7bb834d 100644 --- a/tests/snapshots/ui__ui_test_err_decompress_missing_extension-2.snap +++ b/tests/snapshots/ui__ui_test_err_decompress_missing_extension-2.snap @@ -7,6 +7,6 @@ expression: "run_ouch(\"ouch decompress a b.unknown\", dir)" - Files with missing extensions: /a - Decompression formats are detected automatically from file extension -hint: Supported extensions are: tar, zip, bz, bz2, gz, lz4, xz, lzma, sz, zst +hint: Supported extensions are: tar, zip, bz, bz2, gz, lz4, xz, lzma, sz, zst, rar hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst diff --git a/tests/snapshots/ui__ui_test_err_decompress_missing_extension-3.snap b/tests/snapshots/ui__ui_test_err_decompress_missing_extension-3.snap index 1fd4ab8..0192ef9 100644 --- a/tests/snapshots/ui__ui_test_err_decompress_missing_extension-3.snap +++ b/tests/snapshots/ui__ui_test_err_decompress_missing_extension-3.snap @@ -6,7 +6,7 @@ expression: "run_ouch(\"ouch decompress b.unknown\", dir)" - Files with unsupported extensions: /b.unknown - Decompression formats are detected automatically from file extension -hint: Supported extensions are: tar, zip, bz, bz2, gz, lz4, xz, lzma, sz, zst +hint: Supported extensions are: tar, zip, bz, bz2, gz, lz4, xz, lzma, sz, zst, rar hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst hint: hint: Alternatively, you can pass an extension to the '--format' flag: diff --git a/tests/snapshots/ui__ui_test_err_decompress_missing_extension.snap b/tests/snapshots/ui__ui_test_err_decompress_missing_extension.snap index 9850d48..f74a81a 100644 --- a/tests/snapshots/ui__ui_test_err_decompress_missing_extension.snap +++ b/tests/snapshots/ui__ui_test_err_decompress_missing_extension.snap @@ -6,7 +6,7 @@ expression: "run_ouch(\"ouch decompress a\", dir)" - Files with missing extensions: /a - Decompression formats are detected automatically from file extension -hint: Supported extensions are: tar, zip, bz, bz2, gz, lz4, xz, lzma, sz, zst +hint: Supported extensions are: tar, zip, bz, bz2, gz, lz4, xz, lzma, sz, zst, rar hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst hint: hint: Alternatively, you can pass an extension to the '--format' flag: diff --git a/tests/snapshots/ui__ui_test_ok_decompress.snap b/tests/snapshots/ui__ui_test_ok_decompress.snap index a39f344..8fe15d3 100644 --- a/tests/snapshots/ui__ui_test_ok_decompress.snap +++ b/tests/snapshots/ui__ui_test_ok_decompress.snap @@ -2,7 +2,6 @@ source: tests/ui.rs expression: "run_ouch(\"ouch decompress output.zst\", dir)" --- -[INFO] Failed to confirm the format of `output` by sniffing the contents, file might be misnamed [INFO] Successfully decompressed archive in current directory. [INFO] Files unpacked: 1