Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions src/commands/decompress.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ use crate::{
info, info_accessible,
non_archive::lz4::MultiFrameLz4Decoder,
utils::{
BytesFmt, LZMA_MEMLIMIT_BYTES, LimitedReader, PathFmt, copy_limited_decompression, file_size,
BytesFmt, LZMA_MEMLIMIT_BYTES, LimitedReader, MultiVolumeReader, PathFmt, copy_limited_decompression,
file_size,
io::{ReadSeek, lock_and_flush_output_stdio},
is_path_stdin, resolve_path_conflict, user_wants_to_continue,
is_path_stdin, resolve_path_conflict, split_7z_volumes, user_wants_to_continue,
},
};

Expand Down Expand Up @@ -134,6 +135,12 @@ pub fn decompress_file(options: DecompressOptions) -> Result<()> {

let input_is_stdin = is_path_stdin(options.input_file_path);
let (first_extension, extensions) = split_first_compression_format(&options.formats);
// A split 7z archive is read and removed with all of its volumes
let volumes = if first_extension == SevenZip && extensions.is_empty() {
split_7z_volumes(options.input_file_path)?
} else {
Vec::new()
};

// Grab previous decoder and wrap it inside of a new one
let chain_reader_decoder = |format: &CompressionFormat, decoder: Box<dyn Read>| -> Result<Box<dyn Read>> {
Expand Down Expand Up @@ -244,6 +251,11 @@ pub fn decompress_file(options: DecompressOptions) -> Result<()> {
// Bomb cap: abort if the in-memory decompressed image exceeds the limit
copy_limited_decompression(create_decoder_up_to_first_extension()?, &mut vec)?;
Box::new(io::Cursor::new(vec))
} else if !volumes.is_empty() {
Box::new(BufReader::with_capacity(
BUFFER_CAPACITY,
MultiVolumeReader::open(&volumes)?,
))
} else {
Box::new(BufReader::with_capacity(
BUFFER_CAPACITY,
Expand Down Expand Up @@ -325,8 +337,15 @@ pub fn decompress_file(options: DecompressOptions) -> Result<()> {
}

if !input_is_stdin && options.remove {
fs::remove_file(options.input_file_path)?;
info!("Removed input file {}", PathFmt(options.input_file_path));
let inputs = if volumes.is_empty() {
vec![options.input_file_path.to_path_buf()]
} else {
volumes
};
for path in &inputs {
fs::remove_file(path)?;
info!("Removed input file {}", PathFmt(path));
}
}

Ok(())
Expand Down
12 changes: 9 additions & 3 deletions src/commands/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ use crate::{
list::{self, FileInArchive, ListOptions},
non_archive::lz4::MultiFrameLz4Decoder,
utils::{
LZMA_MEMLIMIT_BYTES, LimitedReader, copy_limited_decompression, io::lock_and_flush_output_stdio,
user_wants_to_continue,
LZMA_MEMLIMIT_BYTES, LimitedReader, MultiVolumeReader, copy_limited_decompression,
io::lock_and_flush_output_stdio, split_7z_volumes, user_wants_to_continue,
},
};

Expand Down Expand Up @@ -150,7 +150,13 @@ pub fn list_archive_contents(
Box::new(archive::sevenz::list_archive(io::Cursor::new(vec), password)?)
} else {
// If it's the only format, we can read the archive directly.
Box::new(archive::sevenz::list_archive(fs::File::open(archive_path)?, password)?)
let volumes = split_7z_volumes(archive_path)?;
if volumes.is_empty() {
Box::new(archive::sevenz::list_archive(fs::File::open(archive_path)?, password)?)
} else {
let reader = BufReader::with_capacity(BUFFER_CAPACITY, MultiVolumeReader::open(&volumes)?);
Box::new(archive::sevenz::list_archive(reader, password)?)
}
}
}
Gzip | Bzip | Bzip3 | Lz4 | Lzma | Xz | Lzip | Snappy | Zstd | Brotli => {
Expand Down
23 changes: 21 additions & 2 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,15 @@ pub fn run(args: CliArgs, question_policy: QuestionPolicy, file_visibility_polic
}
} else {
for path in files.iter() {
let (output_path, mut extensions) = extension::separate_known_extensions_from_name(path)?;
// The first volume archive.7z.001 is named like archive.7z when that name is valid
let split_base = extension::split_7z_base(path);
let (output_path, mut extensions) = match split_base
.as_deref()
.map(extension::separate_known_extensions_from_name)
{
Some(Ok(parsed)) => parsed,
_ => extension::separate_known_extensions_from_name(path)?,
};
let mut output_path = output_path.to_owned();

match check::check_file_signature(path, &extensions, question_policy)? {
Expand Down Expand Up @@ -262,6 +270,9 @@ pub fn run(args: CliArgs, question_policy: QuestionPolicy, file_visibility_polic
if !is_path_stdin(f) {
policy.allow_read(sandbox::canonicalize_for_sandbox(f));
}
for volume in utils::split_7z_volumes(f)? {
policy.allow_read(sandbox::canonicalize_for_sandbox(&volume));
}
}

// Collect the directories the warnings refer to while building the policy.
Expand Down Expand Up @@ -373,7 +384,12 @@ pub fn run(args: CliArgs, question_policy: QuestionPolicy, file_visibility_polic
}
} else {
for path in files.iter() {
let mut extensions = extension::extensions_from_path(path)?;
// The first volume archive.7z.001 is named like archive.7z when that name is valid
let split_base = extension::split_7z_base(path);
let mut extensions = match split_base.as_deref().map(extension::extensions_from_path) {
Some(Ok(extensions)) => extensions,
_ => extension::extensions_from_path(path)?,
};

match check::check_file_signature(path, &extensions, question_policy)? {
CheckFileSignatureControlFlow::HaltProgram => return Ok(()),
Expand Down Expand Up @@ -424,6 +440,9 @@ pub fn run(args: CliArgs, question_policy: QuestionPolicy, file_visibility_polic
let mut policy = SandboxPolicy::new();
for f in &files {
policy.allow_read(sandbox::canonicalize_for_sandbox(f));
for volume in utils::split_7z_volumes(f)? {
policy.allow_read(sandbox::canonicalize_for_sandbox(&volume));
}
}
if let Some(spill_dir) = &rar_spill_dir {
let canon = sandbox::canonicalize_for_sandbox(spill_dir.path());
Expand Down
13 changes: 12 additions & 1 deletion src/extension.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
//! Our representation of all the supported compression formats.

use std::{fmt, path::Path};
use std::{
fmt,
path::{Path, PathBuf},
};

use CompressionFormat::*;
use bstr::ByteSlice;
Expand Down Expand Up @@ -265,6 +268,14 @@ pub fn extensions_from_path(path: &Path) -> Result<Vec<Extension>> {
separate_known_extensions_from_name(path).map(|(_, extensions)| extensions)
}

/// Returns `archive.7z` for the first volume `archive.7z.001` of a split 7z archive.
pub fn split_7z_base(path: &Path) -> Option<PathBuf> {
let name = path.file_name().and_then(<[u8] as ByteSlice>::from_os_str)?;
let (base, number) = name.rsplit_once_str(b".")?;
let (_, extension) = split_extension_at_end(base)?;
(number == b"001" && extension.compression_formats == [SevenZip]).then(|| path.with_extension(""))
}

/// Panics if formats has an empty list of compression formats
pub fn split_first_compression_format(formats: &[Extension]) -> (CompressionFormat, Vec<CompressionFormat>) {
let mut extensions: Vec<CompressionFormat> = flatten_compression_formats(formats);
Expand Down
3 changes: 2 additions & 1 deletion src/utils/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ pub mod io;
pub mod logger;
pub mod threads;

pub use self::{file_visibility::*, formatting::*, fs::*, question::*, utf8::*};
pub use self::{file_visibility::*, formatting::*, fs::*, question::*, utf8::*, volumes::*};
mod file_visibility;
mod formatting;
mod fs;
mod question;
mod utf8;
mod volumes;
136 changes: 136 additions & 0 deletions src/utils/volumes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
//! Split archives whose volumes are plain byte slices of one archive.

use std::{
io::{self, Read, Seek, SeekFrom},
path::{Path, PathBuf},
};

use fs_err as fs;

use crate::extension::split_7z_base;

/// Lists all volumes of a split 7z archive from its first volume `archive.7z.001` and nothing for other paths.
pub fn split_7z_volumes(path: &Path) -> io::Result<Vec<PathBuf>> {
let Some(base) = split_7z_base(path) else {
return Ok(Vec::new());
};
let mut volumes: Vec<PathBuf> = (1..)
.map(|number: u32| {
let mut name = base.clone().into_os_string();
name.push(format!(".{number:03}"));
PathBuf::from(name)
})
// skip a volume that is a symlink instead of following it outside the input set
.take_while(|volume| fs::symlink_metadata(volume).is_ok_and(|meta| meta.is_file()))
.collect();
if volumes.is_empty() {
return Ok(volumes);
}

// Files after the last volume are left out by the archive length from the start header
let mut reader = MultiVolumeReader::open(&volumes)?;
let mut header = [0; 32];
// A header that cannot be read is reported when the archive itself is read
if reader.read_exact(&mut header).is_err() {
return Ok(volumes);
}
// The 32 byte start header holds the next header offset at byte 12 and its size at byte 20
let field = |at: usize| u64::from_le_bytes(header[at..at + 8].try_into().expect("8 bytes"));
let length = 32u64.saturating_add(field(12)).saturating_add(field(20));
volumes.truncate(reader.offsets.partition_point(|&start| start < length));
Ok(volumes)
}

/// Reads files one after another as one seekable stream with only one file open at a time.
pub struct MultiVolumeReader {
paths: Vec<PathBuf>,
/// Start offset of each volume followed by the total length
offsets: Vec<u64>,
position: u64,
open_volume: Option<(usize, fs::File)>,
}

impl MultiVolumeReader {
pub fn open(paths: &[PathBuf]) -> io::Result<Self> {
let mut offsets = vec![0];
for path in paths {
offsets.push(offsets[offsets.len() - 1] + fs::metadata(path)?.len());
}
Ok(Self {
paths: paths.to_vec(),
offsets,
position: 0,
open_volume: None,
})
}
}

impl Read for MultiVolumeReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
// The last volume that starts at or before the position holds it
let index = self.offsets.partition_point(|&start| start <= self.position) - 1;
if index >= self.paths.len() {
return Ok(0);
}
let file = match &mut self.open_volume {
Some((open, file)) if *open == index => file,
slot => &mut slot.insert((index, fs::File::open(&self.paths[index])?)).1,
};
file.seek(SeekFrom::Start(self.position - self.offsets[index]))?;
let remaining = self.offsets[index + 1] - self.position;
let len = buf.len().min(usize::try_from(remaining).unwrap_or(usize::MAX));
let read = file.read(&mut buf[..len])?;
// A volume that shrank after open must not look like the end of the archive
if read == 0 && len > 0 {
return Err(io::ErrorKind::UnexpectedEof.into());
}
self.position += read as u64;
Ok(read)
}
}

impl Seek for MultiVolumeReader {
fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
let total = self.offsets[self.paths.len()];
let position = match pos {
SeekFrom::Start(offset) => Some(offset),
SeekFrom::End(offset) => total.checked_add_signed(offset),
SeekFrom::Current(offset) => self.position.checked_add_signed(offset),
};
self.position = position.ok_or(io::ErrorKind::InvalidInput)?;
Ok(self.position)
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn reads_and_seeks_across_volumes() {
let dir = tempfile::tempdir().unwrap();
let paths: Vec<PathBuf> = ["abc", "", "defg", "h"]
.iter()
.enumerate()
.map(|(index, data)| {
let path = dir.path().join(format!("archive.7z.{:03}", index + 1));
std::fs::write(&path, data).unwrap();
path
})
.collect();
let mut reader = MultiVolumeReader::open(&paths).unwrap();

let mut data = String::new();
reader.read_to_string(&mut data).unwrap();
assert_eq!(data, "abcdefgh");

assert_eq!(reader.seek(SeekFrom::End(-5)).unwrap(), 3);
assert_eq!(reader.seek(SeekFrom::Current(1)).unwrap(), 4);
let mut buf = [0; 3];
reader.read_exact(&mut buf).unwrap();
assert_eq!(&buf, b"efg");
assert!(reader.seek(SeekFrom::Current(-8)).is_err());
assert_eq!(reader.seek(SeekFrom::Start(20)).unwrap(), 20);
assert_eq!(reader.read(&mut buf).unwrap(), 0);
}
}
40 changes: 40 additions & 0 deletions tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2217,3 +2217,43 @@ fn merging_a_rar_asks_before_replacing_each_file() {
.success();
assert_eq!("Testing 123\n", fs::read_to_string(out.join("testfile.txt")).unwrap());
}

/// A split 7z archive is listed and decompressed from its first volume and the remove option deletes only its volumes.
#[test]
fn split_7z_archive() {
let (_tempdir, dir) = testdir().unwrap();
fs::create_dir(dir.join("data")).unwrap();
fs::write(dir.join("data").join("file.txt"), "split content").unwrap();
ouch!("-A", "c", dir.join("data"), dir.join("archive.7z"));
let archive = fs::read(dir.join("archive.7z")).unwrap();
fs::remove_file(dir.join("archive.7z")).unwrap();
for name in ["archive.7z", "backup.tar.7z"] {
for (index, chunk) in archive.chunks(archive.len().div_ceil(3)).enumerate() {
fs::write(dir.join(format!("{name}.{:03}", index + 1)), chunk).unwrap();
}
}
// Same name pattern but after the end of the archive
fs::write(dir.join("archive.7z.004"), "other").unwrap();

let list = crate::utils::cargo_bin()
.current_dir(dir)
.args(["list", "archive.7z.001"])
.assert()
.success();
assert!(list.get_output().stdout.find(b"data/file.txt").is_some());

crate::utils::cargo_bin()
.current_dir(dir)
.args(["decompress", "archive.7z.001", "--remove"])
.assert()
.success();
let file = dir.join("archive").join("data").join("file.txt");
assert_eq!("split content", fs::read_to_string(file).unwrap());
assert!((1..=3).all(|number| !dir.join(format!("archive.7z.{number:03}")).exists()));
assert!(dir.join("archive.7z.004").exists());

// An invalid archive name still finds the format by signature
ouch!("-A", "d", dir.join("backup.tar.7z.001"), "-d", dir.join("backup"));
let file = dir.join("backup").join("data").join("file.txt");
assert_eq!("split content", fs::read_to_string(file).unwrap());
}
Loading