diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1bfae2673..03105e284 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -27,6 +27,7 @@ jobs: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name strategy: matrix: + workspace: ['Cargo.toml', 'fuzz/Cargo.toml', 'cli/Cargo.toml', 'cli/clite/Cargo.toml'] os: [ubuntu-latest, macOS-latest, windows-latest] rustalias: [stable, nightly, msrv] workspace: ['Cargo.toml', 'fuzz/Cargo.toml'] @@ -58,14 +59,18 @@ jobs: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name strategy: matrix: - workspace: ['Cargo.toml', 'fuzz/Cargo.toml'] + workspace: ['Cargo.toml', 'fuzz/Cargo.toml', 'cli/Cargo.toml', 'cli/clite/Cargo.toml'] feature_flag: - "--all-features" - "--no-default-features" - "" - - "--no-default-features --features zip/deflate-flate2-zlib-rs" - - "--no-default-features --features zip/deflate-zopfli" - name: 'Miri ${{ matrix.feature_flag }} ${{ matrix.workspace }}' + include: + # Break out a separate test shard for specific dependencies on their own. + - feature_flag: "--no-default-features --features deflate-flate2-zlib-rs" + workspace: 'Cargo.toml' + - feature_flag: "--no-default-features --features deflate-zopfli" + workspace: 'Cargo.toml' + name: 'Miri ${{ matrix.feature_flag }}' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -102,6 +107,10 @@ jobs: run: cargo fmt --all -- --check - name: fmt fuzz run: cargo fmt --all --manifest-path ${{ github.workspace }}/fuzz/Cargo.toml -- --check + - name: fmt cli + run: cargo fmt --all --manifest-path ${{ github.workspace }}/cli/Cargo.toml -- --check + - name: fmt clite + run: cargo fmt --all --manifest-path ${{ github.workspace }}/cli/clite/Cargo.toml -- --check check_minimal_versions: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name @@ -126,7 +135,7 @@ jobs: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name strategy: matrix: - workspace: ['Cargo.toml', 'fuzz/Cargo.toml'] + workspace: ['Cargo.toml', 'fuzz/Cargo.toml', 'cli/Cargo.toml', 'cli/clite/Cargo.toml'] feature_flag: ["--all-features", "--no-default-features", ""] name: 'Style and docs ${{ matrix.feature_flag }} ${{ matrix.workspace }}' runs-on: ubuntu-latest diff --git a/cli/Cargo.toml b/cli/Cargo.toml new file mode 100644 index 000000000..bcc0cca6e --- /dev/null +++ b/cli/Cargo.toml @@ -0,0 +1,70 @@ +[package] +name = "zip-cli" +version = "0.0.1" +authors = [ + "Danny McClanahan ", +] +license = "MIT" +repository = "https: //github.com/zip-rs/zip2.git" +keywords = ["zip", "archive", "compression", "cli"] +categories = ["command-line-utilities", "compression", "filesystem", "development-tools::build-utils"] +# This field is not as important as in the top-level library API, but as there: +# Any change to rust-version must be reflected also in `README.md` and `.github/workflows/ci.yaml`. +# The MSRV policy is documented in `README.md`. +rust-version = "1.83.0" +description = """ +Binary for creation and manipulation of zip files. + +This package can enable or disable certain dependencies during the build and install process with +cargo features. +""" +edition = "2021" + +# Prevent this from interfering with workspaces +[workspace] +members = ["."] + +[lib] + +[[bin]] +name = "zip-cli" + +[dependencies.zip] +path = ".." +default-features = false + +[features] +aes-crypto = ["zip/aes-crypto"] +bzip2 = ["zip/bzip2"] +chrono = ["zip/chrono"] +_deflate-any = ["zip/_deflate-any"] +deflate64 = ["zip/deflate64"] +deflate = ["zip/deflate"] +deflate-flate2 = ["zip/deflate-flate2"] +deflate-flate2-zlib-rs = ["zip/deflate-flate2-zlib-rs"] +deflate-flate2-zlib = ["zip/deflate-flate2-zlib"] +deflate-zopfli = ["zip/deflate-zopfli"] +lzma = ["zip/lzma"] +ppmd = ["zip/ppmd"] +time = ["zip/time"] +xz = ["zip/xz"] +zstd = ["zip/zstd"] + +# Generate a highly featureful binary by default. +default = [ + "aes-crypto", + "bzip2", + "deflate64", + "deflate", + "lzma", + "ppmd", + "time", + "xz", + "zstd", +] + +[profile.release] +strip = true +lto = true +opt-level = 3 +codegen-units = 1 diff --git a/cli/clite/Cargo.toml b/cli/clite/Cargo.toml new file mode 100644 index 000000000..255d34b86 --- /dev/null +++ b/cli/clite/Cargo.toml @@ -0,0 +1,66 @@ +[package] +name = "zip-clite" +version = "0.0.1" +authors = [ + "Danny McClanahan ", +] +license = "MIT" +repository = "https://github.com/zip-rs/zip2.git" +keywords = ["zip", "archive", "compression", "cli"] +categories = ["command-line-utilities", "compression", "filesystem", "development-tools::build-utils"] +# This field is not as important as in the top-level library API, but as there: +# Any change to rust-version must be reflected also in `README.md` and `.github/workflows/ci.yaml`. +# The MSRV policy is documented in `README.md`. +rust-version = "1.83.0" +description = """ +Binary for creation and manipulation of zip files. + +This distribution is created to be intentionally very small and easy to audit. It has reduced +functionality, builds to optimize for size, and only bundles in support for a Rust +DEFLATE implementation. +""" +edition = "2021" + +# Prevent this from interfering with workspaces +[workspace] +members = ["."] + +[[bin]] +name = "zip-clite" + +# NB: This is not a dependency on the top-level `zip` crate, but the `zip-cli` crate (which mirrors +# the declared features from `zip`). We do not use its `main.rs` entry point, but rely upon +# `lib.rs`, which was specifically designed to minimize the amount of code specific to +# `zip-clite`. +[dependencies.zip-cli] +path = ".." +default-features = false + +[features] +aes-crypto = ["zip-cli/aes-crypto"] +bzip2 = ["zip-cli/bzip2"] +chrono = ["zip-cli/chrono"] +_deflate-any = ["zip-cli/_deflate-any"] +deflate64 = ["zip-cli/deflate64"] +deflate = ["zip-cli/deflate"] +deflate-flate2 = ["zip-cli/deflate-flate2"] +deflate-flate2-zlib-rs = ["zip-cli/deflate-flate2-zlib-rs"] +deflate-flate2-zlib = ["zip-cli/deflate-flate2-zlib"] +deflate-zopfli = ["zip-cli/deflate-zopfli"] +lzma = ["zip-cli/lzma"] +ppmd = ["zip-cli/ppmd"] +time = ["zip-cli/time"] +xz = ["zip-cli/xz"] +zstd = ["zip-cli/zstd"] + +# Only bring in the pure-Rust DEFLATE implementation by default. +default = [ + "deflate-flate2", + "deflate-flate2-zlib-rs", +] + +[profile.release] +strip = true +lto = true +opt-level = "s" +codegen-units = 1 diff --git a/cli/clite/src/main.rs b/cli/clite/src/main.rs new file mode 100644 index 000000000..5a2f0f574 --- /dev/null +++ b/cli/clite/src/main.rs @@ -0,0 +1,5 @@ +use zip_cli::shared_main; + +fn main() { + shared_main() +} diff --git a/cli/src/lib.rs b/cli/src/lib.rs new file mode 100644 index 000000000..a69fbf59a --- /dev/null +++ b/cli/src/lib.rs @@ -0,0 +1,260 @@ +//! Shared entry point for `zip-cli` and `zip-clite`. +//! +//! The difference between the two distributions should be a matter of their selected features and +//! optimization flags, and nothing more. If the two retain a 100% compatible CLI API, users will be +//! able to select the distribution purely based upon the functionality/security they need for that +//! particular use case. + +use std::{ + collections::VecDeque, + env, ffi, fs, + io::{self, Write}, + path, process, +}; + +use zip::{write::SimpleFileOptions, CompressionMethod, ZipArchive, ZipWriter}; + +#[repr(i32)] +enum ExitCode { + Success = 0, + InvalidArg = 1, + InvalidFile = 2, +} + +pub fn shared_main() -> ! { + let mut argv: VecDeque = env::args_os().collect(); + + let this = argv + .pop_front() + .unwrap_or_else(|| unsafe { + ffi::OsString::from_encoded_bytes_unchecked(b"".to_vec()) + }) + .into_string() + .unwrap(); + + let cmd = match argv.pop_front() { + None => { + eprintln!("{this} [compress|extract|info] ..."); + process::exit(ExitCode::InvalidArg as i32) + } + Some(arg) if matches!(arg.as_encoded_bytes(), b"-h" | b"--help") => { + println!("{this} [compress|extract|info] ..."); + process::exit(ExitCode::Success as i32) + } + Some(cmd) => cmd.into_string().unwrap(), + }; + + match cmd.as_str() { + "compress" => compress(this, argv), + "extract" => extract(this, argv), + "info" => info(this, argv), + "-h" | "--help" => { + println!("{this} [compress|extract|info] ..."); + process::exit(ExitCode::Success as i32) + } + cmd => { + eprintln!("unrecognized command name: {cmd}"); + eprintln!("{this} [compress|extract|info] ..."); + process::exit(ExitCode::InvalidArg as i32) + } + } +} + +fn compress(this: String, mut args: VecDeque) -> ! { + let outfile = match args.pop_front() { + None => { + eprintln!("{this} compress outfile.zip"); + eprintln!("(zip entry paths over stdin)"); + process::exit(ExitCode::InvalidArg as i32) + } + Some(arg) if matches!(arg.as_encoded_bytes(), b"-h" | b"--help") => { + println!("{this} compress outfile.zip"); + println!("(zip entry paths over stdin)"); + process::exit(ExitCode::Success as i32) + } + Some(outfile) => outfile, + }; + if !args.is_empty() { + /* Print an error message, but keep going. */ + eprintln!("{this} compress takes no further arguments, but got {args:?}"); + } + + let mut w = match match fs::OpenOptions::new() + .write(true) + .read(true) + .create(true) + .truncate(false) + .open(&outfile) + { + Err(e) => { + eprintln!("error opening compress output file {outfile:?}: {e}"); + process::exit(ExitCode::InvalidFile as i32) + } + Ok(f) => { + if f.metadata().unwrap().len() > 0 { + ZipWriter::new_append(f) + } else { + Ok(ZipWriter::new(f)) + } + } + } { + Err(e) => { + eprintln!("error creating zip writer from output file {outfile:?}: {e}"); + process::exit(ExitCode::InvalidFile as i32) + } + Ok(w) => w, + }; + + let stored = SimpleFileOptions::default().compression_method(CompressionMethod::Stored); + let compressed = zip::cfg_if_expr! { + #[cfg(feature = "_deflate-any")] => SimpleFileOptions::default() + .compression_method(CompressionMethod::Deflated) + .compression_level(Some(9)), + _ => stored, + }; + for line in io::stdin().lines() { + let line = line.unwrap(); + let p = path::Path::new(&line); + if line.ends_with("/") { + w.add_directory_from_path(p, stored).unwrap(); + } else { + match fs::symlink_metadata(p) { + Err(e) => { + /* Error for this entry, but do not exit the whole thing. */ + eprintln!("error reading input file path {p:?}: {e}"); + continue; + } + Ok(m) => { + if m.is_dir() { + w.add_directory_from_path(p, stored).unwrap(); + } else if m.is_symlink() { + let target = fs::read_link(p).unwrap(); + w.add_symlink_from_path(p, target, stored).unwrap(); + } else { + assert!(m.is_file()); + w.start_file_from_path(p, compressed).unwrap(); + let mut f = fs::File::open(p).unwrap(); + let _ = io::copy(&mut f, &mut w).unwrap(); + } + } + } + } + } + w.finish().unwrap(); + + process::exit(ExitCode::Success as i32) +} + +fn extract(this: String, mut args: VecDeque) -> ! { + match args.pop_front() { + None => { + eprintln!("{this} extract [single|all]"); + process::exit(ExitCode::InvalidArg as i32) + } + Some(arg) if matches!(arg.as_encoded_bytes(), b"-h" | b"--help") => { + println!("{this} extract [single|all]"); + process::exit(ExitCode::Success as i32) + } + Some(arg) => match arg.as_encoded_bytes() { + b"single" => extract_single(this, args), + b"all" => extract_all(this, args), + _ => { + eprintln!("unrecognized subcommand {arg:?}"); + eprintln!("{this} extract [single|all]"); + process::exit(ExitCode::InvalidArg as i32) + } + }, + } +} + +fn extract_single(this: String, mut args: VecDeque) -> ! { + let infile = match args.pop_front() { + None => { + eprintln!("{this} extract single infile.zip entry-name"); + process::exit(ExitCode::InvalidArg as i32) + } + Some(arg) if matches!(arg.as_encoded_bytes(), b"-h" | b"--help") => { + println!("{this} extract single infile.zip entry-name"); + process::exit(ExitCode::Success as i32) + } + Some(infile) => infile, + }; + let mut archive = ZipArchive::new(fs::File::open(&infile).unwrap()).unwrap(); + + let entry_name = match args.pop_front() { + None => { + eprintln!("no entry-name provided"); + eprintln!("{this} extract single infile.zip entry-name"); + process::exit(ExitCode::InvalidArg as i32) + } + Some(entry_name) => entry_name.into_string().unwrap(), + }; + + if !args.is_empty() { + /* Print an error message, but keep going. */ + eprintln!("{this} extract single takes no further arguments, but got {args:?}"); + } + + let mut entry = match archive.by_name(&entry_name) { + Err(e) => { + eprintln!("error extracting single entry {entry_name}: {e}"); + process::exit(ExitCode::InvalidFile as i32) + } + Ok(zf) => zf, + }; + + let mut stdout = io::stdout().lock(); + let _ = io::copy(&mut entry, &mut stdout).unwrap(); + stdout.flush().unwrap(); + process::exit(ExitCode::Success as i32) +} + +fn extract_all(this: String, mut args: VecDeque) -> ! { + let infile = match args.pop_front() { + None => { + eprintln!("{this} extract all infile.zip"); + process::exit(ExitCode::InvalidArg as i32) + } + Some(infile) => infile, + }; + let mut archive = ZipArchive::new(fs::File::open(&infile).unwrap()).unwrap(); + + if !args.is_empty() { + /* Print an error message, but keep going. */ + eprintln!("{this} extract all takes no further arguments, but got {args:?}"); + } + + archive.extract(".").unwrap(); + + process::exit(ExitCode::Success as i32) +} + +fn info(this: String, mut args: VecDeque) -> ! { + let infile = match args.pop_front() { + None => { + eprintln!("{this} info infile.zip"); + process::exit(ExitCode::InvalidArg as i32) + } + Some(arg) if matches!(arg.as_encoded_bytes(), b"-h" | b"--help") => { + println!("{this} info infile.zip"); + process::exit(ExitCode::Success as i32) + } + Some(infile) => infile, + }; + let archive = ZipArchive::new(fs::File::open(&infile).unwrap()).unwrap(); + + if !args.is_empty() { + /* Print an error message, but keep going. */ + eprintln!("{this} info takes no further arguments, but got {args:?}"); + } + + let mut stdout = io::stdout().lock(); + + for name in archive.file_names() { + stdout.write_fmt(format_args!("{}\n", name)).unwrap(); + } + + stdout.flush().unwrap(); + + process::exit(ExitCode::Success as i32) +} diff --git a/cli/src/main.rs b/cli/src/main.rs new file mode 100644 index 000000000..5a2f0f574 --- /dev/null +++ b/cli/src/main.rs @@ -0,0 +1,5 @@ +use zip_cli::shared_main; + +fn main() { + shared_main() +} diff --git a/src/compression.rs b/src/compression.rs index 2848f8749..39199b915 100644 --- a/src/compression.rs +++ b/src/compression.rs @@ -4,6 +4,8 @@ use crate::cfg_if_expr; use core::fmt; use std::io; +use crate::cfg_if_expr; + #[allow(deprecated)] /// Identifies the storage format used to compress a file within a ZIP archive. ///