From bcdff0f46b8ff16b4f03d1d574c9f229b4b16f89 Mon Sep 17 00:00:00 2001 From: oxalica Date: Tue, 1 Jul 2025 01:38:42 -0400 Subject: [PATCH 1/6] feat: add listing support for squashfs --- Cargo.lock | 281 ++++++++++++++++++++++++++++++++++++- Cargo.toml | 2 + src/archive/mod.rs | 1 + src/archive/squashfs.rs | 23 +++ src/commands/compress.rs | 10 +- src/commands/decompress.rs | 11 +- src/commands/list.rs | 30 ++-- src/commands/mod.rs | 27 ++-- src/error.rs | 23 +++ src/extension.rs | 11 +- src/utils/fs.rs | 6 + 11 files changed, 385 insertions(+), 40 deletions(-) create mode 100644 src/archive/squashfs.rs diff --git a/Cargo.lock b/Cargo.lock index 71b8cf9..7b5f97d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -126,6 +126,24 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +[[package]] +name = "backhand" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c45726a83c67f85d931ef28d331cbc4108b179e06359ed84944313dfc2bb9ce1" +dependencies = [ + "deku", + "flate2", + "lz4_flex", + "rayon", + "solana-nohash-hasher", + "thiserror 2.0.12", + "tracing", + "xxhash-rust", + "zstd", + "zstd-safe", +] + [[package]] name = "base64ct" version = "1.6.0" @@ -181,6 +199,18 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -284,7 +314,7 @@ dependencies = [ "byteorder", "bytesize", "libbzip3-sys", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -520,6 +550,66 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.98", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "deku" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476a022dcfbb013d1365734a42e05b6aca967ebe0d3bb38170086abd9ea3324" +dependencies = [ + "bitvec", + "deku_derive", + "no_std_io2", + "rustversion", +] + +[[package]] +name = "deku_derive" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb216d425bdf810c165a8ae1649523033e88b5f795480ccec63926295541b084" +dependencies = [ + "darling", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.98", +] + [[package]] name = "deranged" version = "0.4.0" @@ -570,6 +660,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.10" @@ -616,6 +712,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11faaf5a5236997af9848be0bef4db95824b1d534ebc64d0f0c6cf3e67bd38dc" dependencies = [ "crc32fast", + "libz-rs-sys", "libz-sys", "miniz_oxide", ] @@ -648,6 +745,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures-core" version = "0.3.31" @@ -728,9 +831,15 @@ dependencies = [ "libz-sys", "num_cpus", "snap", - "thiserror", + "thiserror 1.0.69", ] +[[package]] +name = "hashbrown" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" + [[package]] name = "heck" version = "0.5.0" @@ -770,6 +879,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "ignore" version = "0.4.23" @@ -786,6 +901,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "indexmap" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "infer" version = "0.16.0" @@ -937,6 +1062,15 @@ dependencies = [ "redox_syscall", ] +[[package]] +name = "libz-rs-sys" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "902bc563b5d65ad9bba616b490842ef0651066a1a1dc3ce1087113ffcb873c8d" +dependencies = [ + "zlib-rs", +] + [[package]] name = "libz-sys" version = "1.1.21" @@ -1036,6 +1170,15 @@ dependencies = [ "getrandom 0.2.15", ] +[[package]] +name = "no_std_io2" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c2b9acd47481ab557a89a5665891be79e43cce8a29ad77aa9419d7be5a7c06a" +dependencies = [ + "memchr", +] + [[package]] name = "nom" version = "7.1.3" @@ -1095,6 +1238,7 @@ version = "0.6.1" dependencies = [ "assert_cmd", "atty", + "backhand", "brotli", "bstr", "bytesize", @@ -1213,6 +1357,12 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + [[package]] name = "pkg-config" version = "0.3.31" @@ -1286,6 +1436,15 @@ dependencies = [ "yansi", ] +[[package]] +name = "proc-macro-crate" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.93" @@ -1330,6 +1489,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.5" @@ -1595,6 +1760,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" +[[package]] +name = "solana-nohash-hasher" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b8a731ed60e89177c8a7ab05fe0f1511cedd3e70e773f288f9de33a9cfdc21e" + [[package]] name = "spin" version = "0.9.8" @@ -1667,6 +1838,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tar" version = "0.4.44" @@ -1716,7 +1893,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl 2.0.12", ] [[package]] @@ -1730,6 +1916,17 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + [[package]] name = "time" version = "0.3.41" @@ -1760,6 +1957,54 @@ dependencies = [ "time-core", ] +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + [[package]] name = "twox-hash" version = "1.6.3" @@ -2058,6 +2303,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74c7b26e3480b707944fc872477815d29a8e429d2f93a1ce000f5fa84a15cbcd" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen-rt" version = "0.33.0" @@ -2067,6 +2321,15 @@ dependencies = [ "bitflags 2.8.0", ] +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "xattr" version = "1.4.0" @@ -2078,6 +2341,12 @@ dependencies = [ "rustix", ] +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + [[package]] name = "xz2" version = "0.1.7" @@ -2152,6 +2421,12 @@ dependencies = [ "time", ] +[[package]] +name = "zlib-rs" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b20717f0917c908dc63de2e44e97f1e6b126ca58d0e391cee86d504eb8fbd05" + [[package]] name = "zstd" version = "0.13.3" diff --git a/Cargo.toml b/Cargo.toml index 5d31493..bf4c460 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,8 @@ description = "A command-line utility for easily compressing and decompressing f [dependencies] atty = "0.2.14" +# FIXME: `xz` features cannot be enabled because it uses `lzma-sys` which cannot co-exist with our dependency `xz2`. +backhand = { version = "0.23.0", default-features = false, features = ["parallel", "gzip", "lz4", "zstd"] } brotli = "7.0.0" bstr = { version = "1.10.0", default-features = false, features = ["std"] } bytesize = "1.3.0" diff --git a/src/archive/mod.rs b/src/archive/mod.rs index 6412a8a..2e037fb 100644 --- a/src/archive/mod.rs +++ b/src/archive/mod.rs @@ -7,5 +7,6 @@ pub mod rar; #[cfg(not(feature = "unrar"))] pub mod rar_stub; pub mod sevenz; +pub mod squashfs; pub mod tar; pub mod zip; diff --git a/src/archive/squashfs.rs b/src/archive/squashfs.rs new file mode 100644 index 0000000..032c2c5 --- /dev/null +++ b/src/archive/squashfs.rs @@ -0,0 +1,23 @@ +use std::path::Path; + +use backhand::{FilesystemReader, InnerNode}; + +use crate::list::FileInArchive; + +pub fn list_archive<'a>(archive: FilesystemReader<'a>) -> impl Iterator> + 'a { + archive.root.nodes.into_iter().filter_map(move |f| { + // The reported paths are absolute, and include the root directory `/`. + // To be consistent with outputs of other formats, we strip the prefix `/` and ignore the root directory. + if f.fullpath == Path::new("/") { + return None; + } + Some(Ok(FileInArchive { + is_dir: matches!(f.inner, InnerNode::Dir(_)), + path: f + .fullpath + .strip_prefix("/") + .expect("paths must be absolute") + .to_path_buf(), + })) + }) +} diff --git a/src/commands/compress.rs b/src/commands/compress.rs index 15880bd..1737359 100644 --- a/src/commands/compress.rs +++ b/src/commands/compress.rs @@ -5,10 +5,9 @@ use std::{ use fs_err as fs; -use super::warn_user_about_loading_sevenz_in_memory; use crate::{ archive, - commands::warn_user_about_loading_zip_in_memory, + commands::warn_user_about_loading_in_memory, extension::{split_first_compression_format, CompressionFormat::*, Extension}, utils::{io::lock_and_flush_output_stdio, user_wants_to_continue, FileVisibilityPolicy}, QuestionAction, QuestionPolicy, BUFFER_CAPACITY, @@ -96,7 +95,7 @@ pub fn compress_files( let win_size = 22; // default to 2^22 = 4 MiB window size Box::new(brotli::CompressorWriter::new(encoder, BUFFER_CAPACITY, level, win_size)) } - Tar | Zip | Rar | SevenZip => unreachable!(), + Tar | Zip | Rar | SevenZip | Squashfs => unreachable!(), }; Ok(encoder) }; @@ -125,13 +124,14 @@ pub fn compress_files( )?; writer.flush()?; } + Squashfs => todo!(), Zip => { if !formats.is_empty() { // Locking necessary to guarantee that warning and question // messages stay adjacent let _locks = lock_and_flush_output_stdio(); - warn_user_about_loading_zip_in_memory(); + warn_user_about_loading_in_memory(".zip"); if !user_wants_to_continue(output_path, question_policy, QuestionAction::Compression)? { return Ok(false); } @@ -163,7 +163,7 @@ pub fn compress_files( // messages stay adjacent let _locks = lock_and_flush_output_stdio(); - warn_user_about_loading_sevenz_in_memory(); + warn_user_about_loading_in_memory(".7z"); if !user_wants_to_continue(output_path, question_policy, QuestionAction::Compression)? { return Ok(false); } diff --git a/src/commands/decompress.rs b/src/commands/decompress.rs index 6c254ad..e620229 100644 --- a/src/commands/decompress.rs +++ b/src/commands/decompress.rs @@ -9,7 +9,7 @@ use fs_err as fs; #[cfg(not(feature = "bzip3"))] use crate::archive; use crate::{ - commands::{warn_user_about_loading_sevenz_in_memory, warn_user_about_loading_zip_in_memory}, + commands::warn_user_about_loading_in_memory, extension::{ split_first_compression_format, CompressionFormat::{self, *}, @@ -65,7 +65,7 @@ pub fn decompress_file(options: DecompressOptions) -> crate::Result<()> { { let mut vec = vec![]; let reader: Box = if input_is_stdin { - warn_user_about_loading_zip_in_memory(); + warn_user_about_loading_in_memory(".zip"); io::copy(&mut io::stdin(), &mut vec)?; Box::new(io::Cursor::new(vec)) } else { @@ -132,7 +132,7 @@ pub fn decompress_file(options: DecompressOptions) -> crate::Result<()> { Snappy => Box::new(snap::read::FrameDecoder::new(decoder)), Zstd => Box::new(zstd::stream::Decoder::new(decoder)?), Brotli => Box::new(brotli::Decompressor::new(decoder, BUFFER_CAPACITY)), - Tar | Zip | Rar | SevenZip => decoder, + Tar | Zip | Rar | SevenZip | Squashfs => decoder, }; Ok(decoder) }; @@ -174,13 +174,14 @@ pub fn decompress_file(options: DecompressOptions) -> crate::Result<()> { return Ok(()); } } + Squashfs => todo!(), Zip => { if options.formats.len() > 1 { // Locking necessary to guarantee that warning and question // messages stay adjacent let _locks = lock_and_flush_output_stdio(); - warn_user_about_loading_zip_in_memory(); + warn_user_about_loading_in_memory(".zip"); if !user_wants_to_continue( options.input_file_path, options.question_policy, @@ -252,7 +253,7 @@ pub fn decompress_file(options: DecompressOptions) -> crate::Result<()> { // messages stay adjacent let _locks = lock_and_flush_output_stdio(); - warn_user_about_loading_sevenz_in_memory(); + warn_user_about_loading_in_memory(".7z"); if !user_wants_to_continue( options.input_file_path, options.question_policy, diff --git a/src/commands/list.rs b/src/commands/list.rs index 4a344a7..9b3ee2a 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -7,7 +7,7 @@ use fs_err as fs; use crate::{ archive, - commands::warn_user_about_loading_zip_in_memory, + commands::warn_user_about_loading_in_memory, extension::CompressionFormat::{self, *}, list::{self, FileInArchive, ListOptions}, utils::{io::lock_and_flush_output_stdio, user_wants_to_continue}, @@ -38,6 +38,13 @@ pub fn list_archive_contents( list::list_files(archive_path, files, list_options)?; return Ok(()); } + if let &[Squashfs] = formats.as_slice() { + let reader = BufReader::with_capacity(BUFFER_CAPACITY, reader); + let archive = backhand::FilesystemReader::from_reader(reader)?; + let files = crate::archive::squashfs::list_archive(archive); + list::list_files(archive_path, files, list_options)?; + return Ok(()); + } // Will be used in decoder chaining let reader = BufReader::with_capacity(BUFFER_CAPACITY, reader); @@ -61,7 +68,7 @@ pub fn list_archive_contents( Snappy => Box::new(snap::read::FrameDecoder::new(decoder)), Zstd => Box::new(zstd::stream::Decoder::new(decoder)?), Brotli => Box::new(brotli::Decompressor::new(decoder, BUFFER_CAPACITY)), - Tar | Zip | Rar | SevenZip => unreachable!("should be treated by caller"), + Tar | Zip | Rar | SevenZip | Squashfs => unreachable!("should be treated by caller"), }; Ok(decoder) }; @@ -78,13 +85,15 @@ pub fn list_archive_contents( let archive_format = misplaced_archive_format.unwrap_or(formats[0]); let files: Box>> = match archive_format { Tar => Box::new(crate::archive::tar::list_archive(tar::Archive::new(reader))), - Zip => { + Zip | Squashfs => { + let is_zip = matches!(archive_format, Zip); + if formats.len() > 1 { // Locking necessary to guarantee that warning and question // messages stay adjacent let _locks = lock_and_flush_output_stdio(); - warn_user_about_loading_zip_in_memory(); + warn_user_about_loading_in_memory(if is_zip { ".zip" } else { ".sqfs" }); if !user_wants_to_continue(archive_path, question_policy, QuestionAction::Decompression)? { return Ok(()); } @@ -92,9 +101,14 @@ pub fn list_archive_contents( let mut vec = vec![]; io::copy(&mut reader, &mut vec)?; - let zip_archive = zip::ZipArchive::new(io::Cursor::new(vec))?; - - Box::new(crate::archive::zip::list_archive(zip_archive, password)) + let reader = io::Cursor::new(vec); + if is_zip { + let zip_archive = zip::ZipArchive::new(reader)?; + Box::new(crate::archive::zip::list_archive(zip_archive, password)) + } else { + let archive = backhand::FilesystemReader::from_reader(reader)?; + Box::new(crate::archive::squashfs::list_archive(archive)) + } } #[cfg(feature = "unrar")] Rar => { @@ -116,7 +130,7 @@ pub fn list_archive_contents( // messages stay adjacent let _locks = lock_and_flush_output_stdio(); - warn_user_about_loading_zip_in_memory(); + warn_user_about_loading_in_memory(".7z"); if !user_wants_to_continue(archive_path, question_policy, QuestionAction::Decompression)? { return Ok(()); } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 4a81d85..ed97c54 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -25,24 +25,15 @@ use crate::{ CliArgs, QuestionPolicy, }; -/// Warn the user that (de)compressing this .zip archive might freeze their system. -fn warn_user_about_loading_zip_in_memory() { - const ZIP_IN_MEMORY_LIMITATION_WARNING: &str = "\n \ - The format '.zip' is limited by design and cannot be (de)compressed with encoding streams.\n \ - When chaining '.zip' with other formats, all (de)compression needs to be done in-memory\n \ - Careful, you might run out of RAM if the archive is too large!"; - - eprintln!("{}[WARNING]{}: {ZIP_IN_MEMORY_LIMITATION_WARNING}", *ORANGE, *RESET); -} - -/// Warn the user that (de)compressing this .7z archive might freeze their system. -fn warn_user_about_loading_sevenz_in_memory() { - const SEVENZ_IN_MEMORY_LIMITATION_WARNING: &str = "\n \ - The format '.7z' is limited by design and cannot be (de)compressed with encoding streams.\n \ - When chaining '.7z' with other formats, all (de)compression needs to be done in-memory\n \ - Careful, you might run out of RAM if the archive is too large!"; - - eprintln!("{}[WARNING]{}: {SEVENZ_IN_MEMORY_LIMITATION_WARNING}", *ORANGE, *RESET); +/// Warn the user that (de)compressing this format might freeze their system. +fn warn_user_about_loading_in_memory(ext: &str) { + eprintln!( + "{}[WARNING]{}:\n \ + The format '{ext}' is limited by design and cannot be (de)compressed with encoding streams.\n \ + When chaining '{ext}' with other formats, all (de)compression needs to be done in-memory\n \ + Careful, you might run out of RAM if the archive is too large!", + *ORANGE, *RESET + ); } /// This function checks what command needs to be run and performs A LOT of ahead-of-time checks diff --git a/src/error.rs b/src/error.rs index b3c33df..37b5cfd 100644 --- a/src/error.rs +++ b/src/error.rs @@ -47,6 +47,10 @@ pub enum Error { UnsupportedFormat { reason: String }, /// Invalid password provided InvalidPassword { reason: String }, + /// From backhand::BackhandError + InvalidSquashfs { reason: String }, + /// From backhand::BackhandError + UnsupportedSquashfs { reason: String }, } /// Alias to std's Result with ouch's Error @@ -158,8 +162,10 @@ impl From for FinalError { Error::Lz4Error { reason } => FinalError::with_title(reason), Error::AlreadyExists { error_title } => FinalError::with_title(error_title).detail("File already exists"), Error::InvalidZipArchive(reason) => FinalError::with_title("Invalid zip archive").detail(reason), + Error::InvalidSquashfs { reason } => FinalError::with_title("Invalid squashfs").detail(reason), Error::PermissionDenied { error_title } => FinalError::with_title(error_title).detail("Permission denied"), Error::UnsupportedZipArchive(reason) => FinalError::with_title("Unsupported zip archive").detail(reason), + Error::UnsupportedSquashfs { reason } => FinalError::with_title("Unsupported squashfs").detail(reason), Error::InvalidFormatFlag { reason, text } => { FinalError::with_title(format!("Failed to parse `--format {}`", os_str_to_str(&text))) .detail(reason) @@ -227,6 +233,23 @@ impl From for Error { } } +impl From for Error { + fn from(err: backhand::BackhandError) -> Self { + use backhand::BackhandError; + match err { + BackhandError::StdIo(io_err) => Self::from(io_err), + err @ (BackhandError::UnsupportedCompression(_) | BackhandError::UnsupportedInode(_)) => { + Self::UnsupportedSquashfs { + reason: err.to_string(), + } + } + err => Self::InvalidSquashfs { + reason: err.to_string(), + }, + } + } +} + #[cfg(feature = "unrar")] impl From for Error { fn from(err: unrar::error::UnrarError) -> Self { diff --git a/src/extension.rs b/src/extension.rs index 48be0cb..f9e9c42 100644 --- a/src/extension.rs +++ b/src/extension.rs @@ -102,6 +102,14 @@ pub enum CompressionFormat { SevenZip, /// .br Brotli, + /// .squashfs, .sqfs + // + // Note: There is not canonical extension for squashfs, we pick the two semi-official ones: + // - `.squashfs`: The most popular one from a quick search on GitHub. Also used by some distros. + // https://github.com/NixOS/nixpkgs/blob/6576d979e9a64d870b1f298a5c598116891a6c25/nixos/modules/installer/cd-dvd/iso-image.nix#L797 + // - `.sqfs`: Mentioned in `man mksquashfs` by squashfs-tools, the reference implementation. + // https://github.com/plougher/squashfs-tools/blob/9f9bbd79016ff04967800f4301c7a9d0024c0c91/Documentation/manpages/mksquashfs.1#L494 + Squashfs, } impl CompressionFormat { @@ -109,7 +117,7 @@ impl CompressionFormat { pub fn archive_format(&self) -> bool { // Keep this match like that without a wildcard `_` so we don't forget to update it match self { - Tar | Zip | Rar | SevenZip => true, + Tar | Zip | Rar | SevenZip | Squashfs => true, Gzip => false, Bzip => false, Bzip3 => false, @@ -144,6 +152,7 @@ fn to_extension(ext: &[u8]) -> Option { b"rar" => &[Rar], b"7z" => &[SevenZip], b"br" => &[Brotli], + b"sqfs" | b"squashfs" => &[Squashfs], _ => return None, }, ext.to_str_lossy(), diff --git a/src/utils/fs.rs b/src/utils/fs.rs index f65f152..53e09c4 100644 --- a/src/utils/fs.rs +++ b/src/utils/fs.rs @@ -158,6 +158,10 @@ pub fn try_infer_extension(path: &Path) -> Option { fn is_sevenz(buf: &[u8]) -> bool { buf.starts_with(&[0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C]) } + fn is_squashfs(buf: &[u8]) -> bool { + // Ref: https://dr-emann.github.io/squashfs/squashfs.html#_the_superblock + buf.starts_with(b"hsqs") + } let buf = { let mut buf = [0; 270]; @@ -195,6 +199,8 @@ pub fn try_infer_extension(path: &Path) -> Option { Some(Extension::new(&[Rar], "rar")) } else if is_sevenz(&buf) { Some(Extension::new(&[SevenZip], "7z")) + } else if is_squashfs(&buf) { + Some(Extension::new(&[Squashfs], "sqfs")) } else { None } From 87cf8529f2a3ab05bcb425f32bf67b0de785cc47 Mon Sep 17 00:00:00 2001 From: oxalica Date: Tue, 1 Jul 2025 02:52:18 -0400 Subject: [PATCH 2/6] feat: add decompressing support for squashfs --- src/archive/squashfs.rs | 116 ++++++++++++++++++++++++++++++++++++- src/commands/decompress.rs | 46 ++++++++++----- 2 files changed, 144 insertions(+), 18 deletions(-) diff --git a/src/archive/squashfs.rs b/src/archive/squashfs.rs index 032c2c5..c2b3465 100644 --- a/src/archive/squashfs.rs +++ b/src/archive/squashfs.rs @@ -1,8 +1,19 @@ -use std::path::Path; +use std::{ + fs, + io::{self, BufWriter, Write}, + path::Path, +}; -use backhand::{FilesystemReader, InnerNode}; +use backhand::{FilesystemReader, InnerNode, SquashfsFileReader}; +use filetime_creation::{set_file_handle_times, set_file_mtime, FileTime}; -use crate::list::FileInArchive; +use crate::{ + list::FileInArchive, + utils::{ + logger::{info, warning}, + Bytes, + }, +}; pub fn list_archive<'a>(archive: FilesystemReader<'a>) -> impl Iterator> + 'a { archive.root.nodes.into_iter().filter_map(move |f| { @@ -21,3 +32,102 @@ pub fn list_archive<'a>(archive: FilesystemReader<'a>) -> impl Iterator, output_folder: &Path, quiet: bool) -> crate::Result { + let mut unpacked_files = 0usize; + + for f in archive.files() { + // `output_folder` should already be created. + if f.fullpath == Path::new("/") { + continue; + } + + let relative_path = f.fullpath.strip_prefix("/").expect("paths must be absolute"); + let file_path = output_folder.join(relative_path); + + let mtime = FileTime::from_unix_time(f.header.mtime.into(), 0); + + let warn_ignored = |inode_type: &str| { + warning(format!("ignored {inode_type} in archive {relative_path:?}")); + }; + + match &f.inner { + InnerNode::Dir(_) => { + if !quiet { + info(format!("extracting directory {file_path:?}")); + } + fs::create_dir(&file_path)?; + // Directory mtime is not recovered. It will be overwritten by + // the creation of inner files. We would need a second pass to do so. + } + InnerNode::File(file) => { + if !quiet { + let file_size = Bytes::new(match file { + SquashfsFileReader::Basic(f) => f.file_size.into(), + SquashfsFileReader::Extended(f) => f.file_size, + }); + info(format!("extracting file ({file_size}) {file_path:?}")); + } + + let mut reader = archive.file(file).reader(); + let output_file = fs::File::create(&file_path)?; + let mut output_file = BufWriter::new(output_file); + io::copy(&mut reader, &mut output_file)?; + output_file.flush()?; + set_file_handle_times(output_file.get_ref(), None, Some(mtime), None)?; + } + InnerNode::Symlink(symlink) => { + if !quiet { + info(format!("extracting symlink {file_path:?}")); + } + + let target = &symlink.link; + #[cfg(unix)] + { + std::os::unix::fs::symlink(&target, &file_path)?; + filetime_creation::set_symlink_file_times(&file_path, mtime, mtime, mtime)?; + // Note: Symlink permissions are ignored on *NIX anyway. No need to set them. + } + + #[cfg(windows)] + std::os::windows::fs::symlink_file(&target, &file_path)?; + + // Symlink mtime is specially handled above. Skip the normal handler. + unpacked_files += 1; + continue; + } + + // TODO: Named pipes and sockets *CAN* be created by unprivileged users. + // Should we extract them by default? + InnerNode::NamedPipe => { + warn_ignored("named pipe"); + continue; + } + InnerNode::Socket => { + warn_ignored("socket"); + continue; + } + + // Not possible without root permission. + InnerNode::CharacterDevice(_) => { + warn_ignored("character device"); + continue; + } + InnerNode::BlockDevice(_) => { + warn_ignored("block device"); + continue; + } + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(&file_path, fs::Permissions::from_mode(f.header.permissions.into()))?; + } + + unpacked_files += 1; + } + + Ok(unpacked_files) +} diff --git a/src/commands/decompress.rs b/src/commands/decompress.rs index e620229..4558327 100644 --- a/src/commands/decompress.rs +++ b/src/commands/decompress.rs @@ -4,6 +4,7 @@ use std::{ path::{Path, PathBuf}, }; +use backhand::BufReadSeek; use fs_err as fs; #[cfg(not(feature = "bzip3"))] @@ -25,9 +26,6 @@ use crate::{ QuestionAction, QuestionPolicy, BUFFER_CAPACITY, }; -trait ReadSeek: Read + io::Seek {} -impl ReadSeek for T {} - pub struct DecompressOptions<'a> { pub input_file_path: &'a Path, pub formats: Vec, @@ -59,21 +57,32 @@ pub fn decompress_file(options: DecompressOptions) -> crate::Result<()> { // // Any other Zip decompression done can take up the whole RAM and freeze ouch. if let [Extension { - compression_formats: [Zip], + compression_formats: [archive_format @ (Zip | Squashfs)], .. }] = options.formats.as_slice() { + let is_zip = matches!(archive_format, Zip); + let mut vec = vec![]; - let reader: Box = if input_is_stdin { - warn_user_about_loading_in_memory(".zip"); + let reader: Box = if input_is_stdin { + warn_user_about_loading_in_memory(if is_zip { ".zip" } else { ".sqfs" }); io::copy(&mut io::stdin(), &mut vec)?; Box::new(io::Cursor::new(vec)) } else { - Box::new(fs::File::open(options.input_file_path)?) + let file = fs::File::open(options.input_file_path)?; + let file = BufReader::new(file); + Box::new(file) }; - let zip_archive = zip::ZipArchive::new(reader)?; let files_unpacked = if let ControlFlow::Continue(files) = execute_decompression( - |output_dir| crate::archive::zip::unpack_archive(zip_archive, output_dir, options.password, options.quiet), + |output_dir| { + if is_zip { + let zip_archive = zip::ZipArchive::new(reader)?; + crate::archive::zip::unpack_archive(zip_archive, output_dir, options.password, options.quiet) + } else { + let archive = backhand::FilesystemReader::from_reader(reader)?; + crate::archive::squashfs::unpack_archive(archive, output_dir, options.quiet) + } + }, options.output_dir, &options.output_file_path, options.question_policy, @@ -174,14 +183,15 @@ pub fn decompress_file(options: DecompressOptions) -> crate::Result<()> { return Ok(()); } } - Squashfs => todo!(), - Zip => { + Zip | Squashfs => { + let is_zip = matches!(first_extension, Zip); + if options.formats.len() > 1 { // Locking necessary to guarantee that warning and question // messages stay adjacent let _locks = lock_and_flush_output_stdio(); - warn_user_about_loading_in_memory(".zip"); + warn_user_about_loading_in_memory(if is_zip { ".zip" } else { ".sqfs" }); if !user_wants_to_continue( options.input_file_path, options.question_policy, @@ -193,11 +203,17 @@ pub fn decompress_file(options: DecompressOptions) -> crate::Result<()> { let mut vec = vec![]; io::copy(&mut reader, &mut vec)?; - let zip_archive = zip::ZipArchive::new(io::Cursor::new(vec))?; + let reader = io::Cursor::new(vec); if let ControlFlow::Continue(files) = execute_decompression( - |output_dir| { - crate::archive::zip::unpack_archive(zip_archive, output_dir, options.password, options.quiet) + move |output_dir| { + if is_zip { + let archive = zip::ZipArchive::new(reader)?; + crate::archive::zip::unpack_archive(archive, output_dir, options.password, options.quiet) + } else { + let archive = backhand::FilesystemReader::from_reader(reader)?; + crate::archive::squashfs::unpack_archive(archive, output_dir, options.quiet) + } }, options.output_dir, &options.output_file_path, From 0121a0c0dfb18c5284d614186edd0710e5ebae36 Mon Sep 17 00:00:00 2001 From: oxalica Date: Wed, 2 Jul 2025 01:15:17 -0400 Subject: [PATCH 3/6] feat: add basic compressing support for squashfs --- src/archive/squashfs.rs | 220 +++++++++++++++++++++++++++++++++++++-- src/commands/compress.rs | 34 ++++-- 2 files changed, 237 insertions(+), 17 deletions(-) diff --git a/src/archive/squashfs.rs b/src/archive/squashfs.rs index c2b3465..d8953c0 100644 --- a/src/archive/squashfs.rs +++ b/src/archive/squashfs.rs @@ -1,17 +1,23 @@ use std::{ - fs, - io::{self, BufWriter, Write}, - path::Path, + env, fs, + io::{self, BufWriter, Seek, Write}, + path::{Path, PathBuf}, + time::SystemTime, }; -use backhand::{FilesystemReader, InnerNode, SquashfsFileReader}; -use filetime_creation::{set_file_handle_times, set_file_mtime, FileTime}; +use backhand::{ + compression::Compressor, FilesystemCompressor, FilesystemReader, FilesystemWriter, InnerNode, NodeHeader, + SquashfsFileReader, +}; +use filetime_creation::{set_file_handle_times, FileTime}; +use same_file::Handle; use crate::{ + error::FinalError, list::FileInArchive, utils::{ logger::{info, warning}, - Bytes, + Bytes, FileVisibilityPolicy, }, }; @@ -131,3 +137,205 @@ pub fn unpack_archive(archive: FilesystemReader<'_>, output_folder: &Path, quiet Ok(unpacked_files) } + +pub fn build_archive_from_paths( + input_filenames: &[PathBuf], + output_path: &Path, + mut writer: W, + file_visibility_policy: FileVisibilityPolicy, + quiet: bool, + follow_symlinks: bool, +) -> crate::Result +where + W: Write + Seek, +{ + let root_dir = match input_filenames { + [path] if path.is_dir() => path, + _ => { + let error = FinalError::with_title("Cannot build squashfs") + .detail("Squashfs requires a single directory input for root directory") + .detail(if input_filenames.len() != 1 { + "Multiple paths are provided".into() + } else { + format!("Not a directory: {:?}", input_filenames[0]) + }); + return Err(error.into()); + } + }; + + let output_handle = Handle::from_path(output_path); + + let mut fs_writer = FilesystemWriter::default(); + // Set the default compression to Gzip with default level, matching mksquashfs's default. + // The default choice of `backhand` is Xz which is not enabled by us. + // TODO: We do not support customization argument for archive formats. + fs_writer.set_compressor(FilesystemCompressor::new(Compressor::Gzip, None).expect("gzip is supported")); + + // cd *into* the source directory, using it as the archive root. + let previous_cwd = env::current_dir()?; + env::set_current_dir(root_dir)?; + + for entry in file_visibility_policy.build_walker(".") { + let entry = entry?; + let path = entry.path(); + + if let Ok(handle) = &output_handle { + if matches!(Handle::from_path(path), Ok(x) if &x == handle) { + warning(format!( + "Cannot compress `{}` into itself, skipping", + output_path.display() + )); + } + } + + if !quiet { + // `fs_writer.push_*` only maintains metadata. We do not want to give + // users a false information that we are compressing during the + // traversal. So this is not "compressing". + // File reading, compression and writing are done in + // `fs_writer.write` below, after the hierarchy tree gets finalized. + info(format!("Found {path:?}")); + } + + let metadata = entry.metadata()?; + let file_type = metadata.file_type(); + + let mut header = NodeHeader::default(); + header.mtime = match metadata.modified() { + // Not available. + Err(_) => 0, + Ok(mtime) => { + let mtime = mtime + .duration_since(SystemTime::UNIX_EPOCH) + .ok() + .and_then(|dur| u32::try_from(dur.as_secs()).ok()); + if mtime.is_none() { + warning(format!( + "Modification time of {path:?} exceeds the representable range (1970-01-01 ~ 2106-02-07) \ + of squashfs. Recorded as 1970-01-01." + )); + } + mtime.unwrap_or(0) + } + }; + + #[cfg(not(unix))] + { + header.permissions = 0o777; + } + + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + // Only permission bits, not file type bits. + header.permissions = metadata.permissions().mode() as u16 & !(libc::S_IFMT as u16); + header.uid = metadata.uid(); + header.gid = metadata.gid(); + } + + // Root directory is special cased. + if path == Path::new(".") { + fs_writer.set_root_mode(header.permissions); + fs_writer.set_root_uid(header.uid); + fs_writer.set_root_gid(header.gid); + continue; + } + + if file_type.is_dir() { + fs_writer.push_dir(path, header)?; + } else if !follow_symlinks && file_type.is_symlink() { + let target = fs::read_link(path)?; + fs_writer.push_symlink(target, path, header)?; + } else if maybe_push_unix_special_inode(&mut fs_writer, path, &metadata, header)? { + // Already handled. + } else { + // Fallback case: read as a regular file. + // See comments of `LazyFile` for why not `File::open` here. + let reader = LazyFile::Path(path.to_path_buf()); + fs_writer.push_file(reader, path, header)?; + } + } + + if !quiet { + info(format!("Compressing data")); + } + + // Finalize the superblock and write data. This should be done before + // resetting current directory, because `LazyFile`s store relative paths. + fs_writer.write(&mut writer)?; + + env::set_current_dir(previous_cwd)?; + + Ok(writer) +} + +#[cfg(not(unix))] +fn maybe_push_unix_special_inode( + _writer: &mut FilesystemWriter, + _path: &Path, + _metadata: &fs::Metadata, + _header: NodeHeader, +) -> io::Result { + Ok(false) +} + +#[cfg(unix)] +fn maybe_push_unix_special_inode( + writer: &mut FilesystemWriter, + path: &Path, + metadata: &fs::Metadata, + header: NodeHeader, +) -> io::Result { + use std::os::unix::fs::{FileTypeExt, MetadataExt}; + + let file_type = metadata.file_type(); + if file_type.is_fifo() { + writer.push_fifo(path, header)?; + } else if file_type.is_socket() { + writer.push_socket(path, header)?; + } else if file_type.is_block_device() { + let dev = metadata.rdev() as u32; + writer.push_block_device(dev, path, header)?; + } else if file_type.is_char_device() { + let dev = metadata.rdev() as u32; + writer.push_char_device(dev, path, header)?; + } else { + return Ok(false); + } + Ok(true) +} + +/// Delay file opening until the first read and close it as soon as the EOF is encountered. +/// +/// Due to design of `backhand`, we need to store all `impl Read` into the +/// builder during traversal and write out the squashfs later. But we cannot +/// open and store all file handles during traversal or it will exhaust file +/// descriptors on *NIX if there are thousands of files (a pretty low limit!). +/// +/// Upstream discussion: https://github.com/wcampbell0x2a/backhand/discussions/614 +enum LazyFile { + Path(PathBuf), + Opened(fs::File), + Closed, +} + +impl io::Read for LazyFile { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + match self { + LazyFile::Path(path) => { + let file = fs::File::open(path)?; + *self = Self::Opened(file); + self.read(buf) + } + LazyFile::Opened(file) => { + let cnt = file.read(buf)?; + if buf.len() != 0 && cnt == 0 { + *self = Self::Closed; + } + Ok(cnt) + } + LazyFile::Closed => Ok(0), + } + } +} diff --git a/src/commands/compress.rs b/src/commands/compress.rs index 1737359..0a0c488 100644 --- a/src/commands/compress.rs +++ b/src/commands/compress.rs @@ -124,29 +124,41 @@ pub fn compress_files( )?; writer.flush()?; } - Squashfs => todo!(), - Zip => { + Zip | Squashfs => { + let is_zip = matches!(first_format, Zip); + if !formats.is_empty() { // Locking necessary to guarantee that warning and question // messages stay adjacent let _locks = lock_and_flush_output_stdio(); - warn_user_about_loading_in_memory(".zip"); + warn_user_about_loading_in_memory(if is_zip { ".zip" } else { ".sqfs" }); if !user_wants_to_continue(output_path, question_policy, QuestionAction::Compression)? { return Ok(false); } } let mut vec_buffer = Cursor::new(vec![]); + if is_zip { + archive::zip::build_archive_from_paths( + &files, + output_path, + &mut vec_buffer, + file_visibility_policy, + quiet, + follow_symlinks, + )?; + } else { + archive::squashfs::build_archive_from_paths( + &files, + output_path, + &mut vec_buffer, + file_visibility_policy, + quiet, + follow_symlinks, + )?; + } - archive::zip::build_archive_from_paths( - &files, - output_path, - &mut vec_buffer, - file_visibility_policy, - quiet, - follow_symlinks, - )?; vec_buffer.rewind()?; io::copy(&mut vec_buffer, &mut writer)?; } From bef287039a380ea8179694cf86045de4d87babb6 Mon Sep 17 00:00:00 2001 From: oxalica Date: Wed, 2 Jul 2025 01:54:09 -0400 Subject: [PATCH 4/6] fix: update tests, comments and docs --- README.md | 6 +++--- src/commands/decompress.rs | 4 ++-- src/commands/list.rs | 2 +- src/extension.rs | 10 ++++++---- ...st_err_decompress_missing_extension_with_rar-1.snap | 4 ++-- ...st_err_decompress_missing_extension_with_rar-2.snap | 4 ++-- ...st_err_decompress_missing_extension_with_rar-3.snap | 4 ++-- .../ui__ui_test_err_format_flag_with_rar-1.snap | 4 ++-- .../ui__ui_test_err_format_flag_with_rar-2.snap | 4 ++-- .../ui__ui_test_err_format_flag_with_rar-3.snap | 4 ++-- 10 files changed, 24 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index ae34414..8cb0a2f 100644 --- a/README.md +++ b/README.md @@ -111,9 +111,9 @@ Output: # Supported formats -| Format | `.tar` | `.zip` | `7z` | `.gz` | `.xz`, `.lzma` | `.bz`, `.bz2` | `.bz3` | `.lz4` | `.sz` (Snappy) | `.zst` | `.rar` | `.br` | -|:---------:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:| -| Supported | ✓ | ✓¹ | ✓¹ | ✓² | ✓ | ✓ | ✓ | ✓ | ✓² | ✓² | ✓³ | ✓ | +| Format | `.tar` | `.zip` | `7z` | `.gz` | `.xz`, `.lzma` | `.bz`, `.bz2` | `.bz3` | `.lz4` | `.sz` (Snappy) | `.zst` | `.rar` | `.br` | `.sqfs`, `.squashfs` | +|:---------:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| Supported | ✓ | ✓¹ | ✓¹ | ✓² | ✓ | ✓ | ✓ | ✓ | ✓² | ✓² | ✓³ | ✓ | ✓¹ | ✓: Supports compression and decompression. diff --git a/src/commands/decompress.rs b/src/commands/decompress.rs index 4558327..6a4bfff 100644 --- a/src/commands/decompress.rs +++ b/src/commands/decompress.rs @@ -49,13 +49,13 @@ pub fn decompress_file(options: DecompressOptions) -> crate::Result<()> { assert!(options.output_dir.exists()); let input_is_stdin = is_path_stdin(options.input_file_path); - // Zip archives are special, because they require io::Seek, so it requires it's logic separated + // Zip and squashfs archives are special, because they require io::Seek, so it requires it's logic separated // from decoder chaining. // // This is the only case where we can read and unpack it directly, without having to do // in-memory decompression/copying first. // - // Any other Zip decompression done can take up the whole RAM and freeze ouch. + // Any other decompression done can take up the whole RAM and freeze ouch. if let [Extension { compression_formats: [archive_format @ (Zip | Squashfs)], .. diff --git a/src/commands/list.rs b/src/commands/list.rs index 9b3ee2a..6c6f027 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -25,7 +25,7 @@ pub fn list_archive_contents( ) -> crate::Result<()> { let reader = fs::File::open(archive_path)?; - // Zip archives are special, because they require io::Seek, so it requires it's logic separated + // Zip and squashfs archives are special, because they require io::Seek, so it requires it's logic separated // from decoder chaining. // // This is the only case where we can read and unpack it directly, without having to do diff --git a/src/extension.rs b/src/extension.rs index f9e9c42..bbe44d4 100644 --- a/src/extension.rs +++ b/src/extension.rs @@ -25,16 +25,18 @@ pub const SUPPORTED_EXTENSIONS: &[&str] = &[ "rar", "7z", "br", + // TODO(review): Which to use as "official" extension? squashfs or sqfs? + "sqfs", ]; -pub const SUPPORTED_ALIASES: &[&str] = &["tgz", "tbz", "tlz4", "txz", "tzlma", "tsz", "tzst"]; +pub const SUPPORTED_ALIASES: &[&str] = &["tgz", "tbz", "tlz4", "txz", "tzlma", "tsz", "tzst", "squashfs"]; #[cfg(not(feature = "unrar"))] -pub const PRETTY_SUPPORTED_EXTENSIONS: &str = "tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, 7z"; +pub const PRETTY_SUPPORTED_EXTENSIONS: &str = "tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, 7z, sqfs"; #[cfg(feature = "unrar")] -pub const PRETTY_SUPPORTED_EXTENSIONS: &str = "tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, rar, 7z"; +pub const PRETTY_SUPPORTED_EXTENSIONS: &str = "tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, rar, 7z, sqfs"; -pub const PRETTY_SUPPORTED_ALIASES: &str = "tgz, tbz, tlz4, txz, tzlma, tsz, tzst"; +pub const PRETTY_SUPPORTED_ALIASES: &str = "tgz, tbz, tlz4, txz, tzlma, tsz, tzst, squashfs"; /// A wrapper around `CompressionFormat` that allows combinations like `tgz` #[derive(Debug, Clone)] diff --git a/tests/snapshots/ui__ui_test_err_decompress_missing_extension_with_rar-1.snap b/tests/snapshots/ui__ui_test_err_decompress_missing_extension_with_rar-1.snap index 61ffed6..6f4def5 100644 --- a/tests/snapshots/ui__ui_test_err_decompress_missing_extension_with_rar-1.snap +++ b/tests/snapshots/ui__ui_test_err_decompress_missing_extension_with_rar-1.snap @@ -6,8 +6,8 @@ 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, bz3, gz, lz4, xz, lzma, sz, zst, rar, 7z -hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst +hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, rar, 7z, sqfs +hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst, squashfs hint: hint: Alternatively, you can pass an extension to the '--format' flag: hint: ouch decompress /a --format tar.gz diff --git a/tests/snapshots/ui__ui_test_err_decompress_missing_extension_with_rar-2.snap b/tests/snapshots/ui__ui_test_err_decompress_missing_extension_with_rar-2.snap index fe1bbf9..05804ed 100644 --- a/tests/snapshots/ui__ui_test_err_decompress_missing_extension_with_rar-2.snap +++ b/tests/snapshots/ui__ui_test_err_decompress_missing_extension_with_rar-2.snap @@ -7,5 +7,5 @@ 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, bz3, gz, lz4, xz, lzma, sz, zst, rar, 7z -hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst +hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, rar, 7z, sqfs +hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst, squashfs diff --git a/tests/snapshots/ui__ui_test_err_decompress_missing_extension_with_rar-3.snap b/tests/snapshots/ui__ui_test_err_decompress_missing_extension_with_rar-3.snap index 0ef66a2..3066279 100644 --- a/tests/snapshots/ui__ui_test_err_decompress_missing_extension_with_rar-3.snap +++ b/tests/snapshots/ui__ui_test_err_decompress_missing_extension_with_rar-3.snap @@ -6,8 +6,8 @@ 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, bz3, gz, lz4, xz, lzma, sz, zst, rar, 7z -hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst +hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, rar, 7z, sqfs +hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst, squashfs hint: hint: Alternatively, you can pass an extension to the '--format' flag: hint: ouch decompress /b.unknown --format tar.gz diff --git a/tests/snapshots/ui__ui_test_err_format_flag_with_rar-1.snap b/tests/snapshots/ui__ui_test_err_format_flag_with_rar-1.snap index 5cb36e4..75e4633 100644 --- a/tests/snapshots/ui__ui_test_err_format_flag_with_rar-1.snap +++ b/tests/snapshots/ui__ui_test_err_format_flag_with_rar-1.snap @@ -5,8 +5,8 @@ expression: "run_ouch(\"ouch compress input output --format tar.gz.unknown\", di [ERROR] Failed to parse `--format tar.gz.unknown` - Unsupported extension 'unknown' -hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, rar, 7z -hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst +hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, rar, 7z, sqfs +hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst, squashfs hint: hint: Examples: hint: --format tar diff --git a/tests/snapshots/ui__ui_test_err_format_flag_with_rar-2.snap b/tests/snapshots/ui__ui_test_err_format_flag_with_rar-2.snap index a1196c7..9009e2f 100644 --- a/tests/snapshots/ui__ui_test_err_format_flag_with_rar-2.snap +++ b/tests/snapshots/ui__ui_test_err_format_flag_with_rar-2.snap @@ -5,8 +5,8 @@ expression: "run_ouch(\"ouch compress input output --format targz\", dir)" [ERROR] Failed to parse `--format targz` - Unsupported extension 'targz' -hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, rar, 7z -hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst +hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, rar, 7z, sqfs +hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst, squashfs hint: hint: Examples: hint: --format tar diff --git a/tests/snapshots/ui__ui_test_err_format_flag_with_rar-3.snap b/tests/snapshots/ui__ui_test_err_format_flag_with_rar-3.snap index 4269a23..af4b677 100644 --- a/tests/snapshots/ui__ui_test_err_format_flag_with_rar-3.snap +++ b/tests/snapshots/ui__ui_test_err_format_flag_with_rar-3.snap @@ -5,8 +5,8 @@ expression: "run_ouch(\"ouch compress input output --format .tar.$#!@.rest\", di [ERROR] Failed to parse `--format .tar.$#!@.rest` - Unsupported extension '$#!@' -hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, rar, 7z -hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst +hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, rar, 7z, sqfs +hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst, squashfs hint: hint: Examples: hint: --format tar From 40c1c1f4e18609aadd9a438850ae80cee3ff6494 Mon Sep 17 00:00:00 2001 From: oxalica Date: Wed, 2 Jul 2025 02:47:32 -0400 Subject: [PATCH 5/6] fix: clippy warnings --- src/archive/squashfs.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/archive/squashfs.rs b/src/archive/squashfs.rs index d8953c0..60d4b44 100644 --- a/src/archive/squashfs.rs +++ b/src/archive/squashfs.rs @@ -90,7 +90,7 @@ pub fn unpack_archive(archive: FilesystemReader<'_>, output_folder: &Path, quiet let target = &symlink.link; #[cfg(unix)] { - std::os::unix::fs::symlink(&target, &file_path)?; + std::os::unix::fs::symlink(target, &file_path)?; filetime_creation::set_symlink_file_times(&file_path, mtime, mtime, mtime)?; // Note: Symlink permissions are ignored on *NIX anyway. No need to set them. } @@ -138,6 +138,8 @@ pub fn unpack_archive(archive: FilesystemReader<'_>, output_folder: &Path, quiet Ok(unpacked_files) } +// Re-assignments work bettwe with `cfg` blocks. +#[allow(clippy::field_reassign_with_default)] pub fn build_archive_from_paths( input_filenames: &[PathBuf], output_path: &Path, @@ -258,7 +260,7 @@ where } if !quiet { - info(format!("Compressing data")); + info("Compressing data".to_string()); } // Finalize the superblock and write data. This should be done before @@ -330,7 +332,7 @@ impl io::Read for LazyFile { } LazyFile::Opened(file) => { let cnt = file.read(buf)?; - if buf.len() != 0 && cnt == 0 { + if !buf.is_empty() && cnt == 0 { *self = Self::Closed; } Ok(cnt) From 802c45d6cd4ac16921ae792566737be12d67efbe Mon Sep 17 00:00:00 2001 From: oxalica Date: Wed, 2 Jul 2025 02:58:26 -0400 Subject: [PATCH 6/6] fix: update ui tests without rar --- ...i_test_err_decompress_missing_extension_without_rar-1.snap | 4 ++-- ...i_test_err_decompress_missing_extension_without_rar-2.snap | 4 ++-- ...i_test_err_decompress_missing_extension_without_rar-3.snap | 4 ++-- .../snapshots/ui__ui_test_err_format_flag_without_rar-1.snap | 4 ++-- .../snapshots/ui__ui_test_err_format_flag_without_rar-2.snap | 4 ++-- .../snapshots/ui__ui_test_err_format_flag_without_rar-3.snap | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/snapshots/ui__ui_test_err_decompress_missing_extension_without_rar-1.snap b/tests/snapshots/ui__ui_test_err_decompress_missing_extension_without_rar-1.snap index 2885d83..51aeceb 100644 --- a/tests/snapshots/ui__ui_test_err_decompress_missing_extension_without_rar-1.snap +++ b/tests/snapshots/ui__ui_test_err_decompress_missing_extension_without_rar-1.snap @@ -6,8 +6,8 @@ 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, bz3, gz, lz4, xz, lzma, sz, zst, 7z -hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst +hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, 7z, sqfs +hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst, squashfs hint: hint: Alternatively, you can pass an extension to the '--format' flag: hint: ouch decompress /a --format tar.gz diff --git a/tests/snapshots/ui__ui_test_err_decompress_missing_extension_without_rar-2.snap b/tests/snapshots/ui__ui_test_err_decompress_missing_extension_without_rar-2.snap index 9cdcbd6..46ce180 100644 --- a/tests/snapshots/ui__ui_test_err_decompress_missing_extension_without_rar-2.snap +++ b/tests/snapshots/ui__ui_test_err_decompress_missing_extension_without_rar-2.snap @@ -7,5 +7,5 @@ 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, bz3, gz, lz4, xz, lzma, sz, zst, 7z -hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst +hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, 7z, sqfs +hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst, squashfs diff --git a/tests/snapshots/ui__ui_test_err_decompress_missing_extension_without_rar-3.snap b/tests/snapshots/ui__ui_test_err_decompress_missing_extension_without_rar-3.snap index 5e57d8b..9faa818 100644 --- a/tests/snapshots/ui__ui_test_err_decompress_missing_extension_without_rar-3.snap +++ b/tests/snapshots/ui__ui_test_err_decompress_missing_extension_without_rar-3.snap @@ -6,8 +6,8 @@ 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, bz3, gz, lz4, xz, lzma, sz, zst, 7z -hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst +hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, 7z, sqfs +hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst, squashfs hint: hint: Alternatively, you can pass an extension to the '--format' flag: hint: ouch decompress /b.unknown --format tar.gz diff --git a/tests/snapshots/ui__ui_test_err_format_flag_without_rar-1.snap b/tests/snapshots/ui__ui_test_err_format_flag_without_rar-1.snap index cd7ccbc..d939678 100644 --- a/tests/snapshots/ui__ui_test_err_format_flag_without_rar-1.snap +++ b/tests/snapshots/ui__ui_test_err_format_flag_without_rar-1.snap @@ -5,8 +5,8 @@ expression: "run_ouch(\"ouch compress input output --format tar.gz.unknown\", di [ERROR] Failed to parse `--format tar.gz.unknown` - Unsupported extension 'unknown' -hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, 7z -hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst +hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, 7z, sqfs +hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst, squashfs hint: hint: Examples: hint: --format tar diff --git a/tests/snapshots/ui__ui_test_err_format_flag_without_rar-2.snap b/tests/snapshots/ui__ui_test_err_format_flag_without_rar-2.snap index 0913264..a846b56 100644 --- a/tests/snapshots/ui__ui_test_err_format_flag_without_rar-2.snap +++ b/tests/snapshots/ui__ui_test_err_format_flag_without_rar-2.snap @@ -5,8 +5,8 @@ expression: "run_ouch(\"ouch compress input output --format targz\", dir)" [ERROR] Failed to parse `--format targz` - Unsupported extension 'targz' -hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, 7z -hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst +hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, 7z, sqfs +hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst, squashfs hint: hint: Examples: hint: --format tar diff --git a/tests/snapshots/ui__ui_test_err_format_flag_without_rar-3.snap b/tests/snapshots/ui__ui_test_err_format_flag_without_rar-3.snap index 7d9b12c..7b8e004 100644 --- a/tests/snapshots/ui__ui_test_err_format_flag_without_rar-3.snap +++ b/tests/snapshots/ui__ui_test_err_format_flag_without_rar-3.snap @@ -5,8 +5,8 @@ expression: "run_ouch(\"ouch compress input output --format .tar.$#!@.rest\", di [ERROR] Failed to parse `--format .tar.$#!@.rest` - Unsupported extension '$#!@' -hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, 7z -hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst +hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, 7z, sqfs +hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst, squashfs hint: hint: Examples: hint: --format tar