Rust Project: Building a Decompression Program
1. Why Do We Need a Custom Decompression CLI?
In the first installment of the 50 Rust Projects series, we built a file compression utility using the flate2 crate. While flate2 is fully capable of handling decompression as well, relying on it exclusively for unpacking archives can shield developers from lower-level mechanics.
To expand our knowledge of Rust's ecosystem and file system handling, this project uses the dedicated zip crate to unpack complex archives. Beyond simply extracting flat files, a robust decompression tool must dynamically reconstruct nested folder hierarchies, validate paths to prevent security vulnerabilities, manage file permissions, and handle command-line interface (CLI) arguments correctly.
2. Core Mental Model: The Archive Traversal Workflow
Unzipping an archive is not merely dumping raw bytes onto a disk; it requires parsing a structured file container, inspecting each entry's metadata, ensuring target directories exist, and streaming bytes into newly created destination files.
3. Project Initialization and Directory Setup
To get started from scratch, we create a new binary crate using Cargo and open our workspace in VS Code.
cargo new decompress_yt
cd decompress_yt
code .
Our project structure consists of the standard generated files:
To work with file systems and input/output streams, we bring standard library modules into scope alongside our external dependencies.
use std::fs;
use std::io;
4. Design Pattern: The Dual-Main Architecture
In approximately 40% to 50% of advanced Rust CLI applications, developers use a dual-function pattern separating the entry-level main() function from an underlying real_main() worker function.
Why Use a Real Main Pattern?
- Clean Process Exits: The
real_mainfunction returns aResultor an integer exit code (e.g.,Result<(), Box<dyn Error>>ori32), allowing idiomatic?error propagation. - Separation of Concerns:
fn main()acts purely as a shell wrapper that catches exit codes and terminates cleanly without boilerplate error dumping.
fn main() {
// Shell wrapper handling process exit codes cleanly
std::process::exit(real_main());
}
fn real_main() -> i32 {
// Actual business logic and error propagation live here
0
}
5. CLI Argument Parsing and Validation
Our program requires a target zip file path passed as a command-line argument. We read the environment arguments into a vector and validate that the user provided the required file name.
use std::env;
fn real_main() -> i32 {
let args: Vec<String> = env::args().collect();
// Check if user provided both the binary name and the target zip file
if args.len() < 2 {
println!("Usage: {} <file_name.zip>", args[0]);
return 1;
}
let fname = std::path::Path::new(&args[1]);
// Further processing continues here...
0
}
6. Opening the Archive and Iterating Through Entries
Once we have validated the file path, we open the file using standard file IO and wrap it inside a zip::ZipArchive reader. Because we need to read through and extract multiple files sequentially, the archive reader must be mutable.
let file = fs::File::open(fname).unwrap();
let mut archive = zip::ZipArchive::new(file).unwrap();
// Loop through every entry index inside the zip container
for i in 0..archive.len() {
let mut file = archive.by_index(i).unwrap();
// Extract and inspect individual entry metadata...
}
7. Security Best Practices: Path Validation & enclosed_name()
Note on Security: When extracting untrusted zip archives, careless handling of absolute paths or directory traversal sequences (like
../../) can allow a malicious archive to overwrite critical system files.
To prevent directory traversal vulnerabilities, we utilize safe path resolution methods recommended by the zip crate documentation:
let out_path = match file.enclosed_name() {
Some(path) => path.to_owned(),
None => continue,
};
enclosed_name(): Validates that the file path inside the archive does not escape the target extraction directory.to_owned(): Clones the borrowed path data so we can manipulate and write it independently on our local disk.
8. Handling Comments and Directory Hierarchies
Archives can contain embedded metadata comments for files, which we can optionally inspect. More importantly, our program must inspect whether an entry represents a directory or a regular file, creating directory trees recursively when necessary.
let comment = file.comment();
if !comment.is_empty() {
println!("File {} comment: {}", i, comment);
}
// Check if the current entry name ends with a slash (indicating a directory)
if (*file.name()).ends_with('/') {
println!("File extracted to {}", out_path.display());
fs::create_dir_all(&out_path).unwrap();
} else {
// Handle file extraction and parent directory creation...
}
9. Complete Code Implementation (src/main.rs)
Below is the complete, production-ready source code integrating argument parsing, zip archive iteration, safe path extraction, directory creation, file copying, and Unix permission configuration.
use std::fs;
use std::io;
use std::env;
fn main() {
std::process::exit(real_main());
}
fn real_main() -> i32 {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
println!("Usage: {} <file_name.zip>", args[0]);
return 1;
}
let fname = std::path::Path::new(&args[1]);
let file = fs::File::open(fname).unwrap();
let mut archive = zip::ZipArchive::new(file).unwrap();
for i in 0..archive.len() {
let mut file = archive.by_index(i).unwrap();
let out_path = match file.enclosed_name() {
Some(path) => path.to_owned(),
None => continue,
};
{
let comment = file.comment();
if !comment.is_empty() {
println!("File {} comment: {}", i, comment);
}
}
if (*file.name()).ends_with('/') {
println!("File extracted to {}", out_path.display());
fs::create_dir_all(&out_path).unwrap();
} else {
if let Some(p) = out_path.parent() {
if !p.exists() {
fs::create_dir_all(p).unwrap();
}
}
let mut out_file = fs::File::create(&out_path).unwrap();
io::copy(&mut file, &mut out_file).unwrap();
println!(
"File extracted to \"{}\" ({} bytes)",
out_path.display(),
file.size()
);
}
// Set Unix file permissions if applicable
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Some(mode) = file.unix_mode() {
fs::set_permissions(&out_path, fs::Permissions::from_mode(mode)).unwrap();
}
}
}
println!("Successfully extracted all files!");
0
}
10. Configuring Dependencies (Cargo.toml)
To resolve external crate dependencies for parsing zip containers, add the zip crate version 0.6.2 to your Cargo.toml:
[package]
name = "decompress_yt"
version = "0.1.0"
edition = "2021"
[dependencies]
zip = "0.6.2"
11. Testing and Running the Application
To run our decompression utility against a sample zip archive (1.zip), execute the following cargo command in your terminal:
cargo run -- 1.zip
Expected Output Walkthrough
Compiling decompress_yt v0.1.0 (/path/to/decompress_yt)
Finished dev [unoptimized + debuginfo] target(s) in 0.45s
Running `target/debug/decompress_yt 1.zip`
File extracted to "one/try"
File extracted to "one/go_2.jpg" (142530 bytes)
File extracted to "one/try/carbon.png" (58421 bytes)
Successfully extracted all files!
12. Summary and Key Takeaways
- Modular Ecosystem Exposure: Moving beyond wrapper libraries like
flate2exposes developers to specialized tools like thezipcrate for deep archive manipulation. - The Dual-Main Pattern: Adopting a
fn main()shell wrapper around a worker function (real_main() -> i32) enables clean process termination and idiomatic error handling. - CLI Argument Validation: Always inspect
env::args()length to ensure required inputs (like archive file paths) are supplied before processing. - Security via
enclosed_name(): Validate extracted paths to neutralize path traversal attacks (../../) that could otherwise overwrite critical system files. - Recursive Directory Generation: Use
fs::create_dir_all()and parent path checks (out_path.parent()) to dynamically recreate complex nested folder hierarchies found within archives. - Cross-Platform Permissions: Leverage conditional compilation attributes (
#[cfg(unix)]) and traits (std::os::unix::fs::PermissionsExt) to preserve correct file execution and read permissions.