From 2d4dc9db6159e4fa8f8374e3c006fa08cc213008 Mon Sep 17 00:00:00 2001 From: Yasunobu <42543015+P4suta@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:28:59 +0900 Subject: [PATCH 1/8] feat(7z): multi-folder coder graph, one active decoder [RM-303] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single-folder 7z seek-reader model with a Vec: - parse_streams_info now yields one FolderInfo per folder. Drops the num_pack != 1 and NumFolders == 1 refusals; a single coder still means one pack stream per folder, so pack streams and folders pair 1:1 and each folder gets its own absolute pack offset from the shared base. - Generalize SubStreamsInfo across folders: per-folder substream sizes (single-substream folders omit their stored size) and the CRC digest count rule numDigests = Σ (n==1 && folderCrc ? 0 : n). Folder CRC presence is tracked per folder via read_digests_defined. - FileRec carries (folder_index, offset_in_folder); content files draw down a flat folder-then-file substream list, resetting the per-folder offset on each folder change. - SevenZSeekReader keeps exactly one folder decoder live at a time. It is built lazily on first use of each folder and torn down (reclaiming the raw source, freeing the codec workspace) before seeking to and building the next — bounded memory regardless of folder count. Each folder is finalized (exact size, no trailing bytes) before switching and at end of stream. - Encoded next-header decoding keeps its single-folder restriction. Still LZMA/LZMA2, single coder per folder; the writer is unchanged (one solid LZMA2 folder). #![forbid(unsafe_code)] holds; no new deps. Differential tests: non-solid archives (each file its own folder) from sevenz-rust2 via push_archive_entry, for both LZMA2 and plain LZMA, with interleaved directory/empty-file entries. Co-Authored-By: Claude Opus 4.8 (1M context) --- libarchive_oxide/src/sevenz.rs | 560 ++++++++++++------ libarchive_oxide/tests/sevenz_differential.rs | 72 +++ 2 files changed, 448 insertions(+), 184 deletions(-) diff --git a/libarchive_oxide/src/sevenz.rs b/libarchive_oxide/src/sevenz.rs index 31ddf22..a0f3841 100644 --- a/libarchive_oxide/src/sevenz.rs +++ b/libarchive_oxide/src/sevenz.rs @@ -4,9 +4,12 @@ //! Seek-capable, payload-streaming 7z reader and writer. //! -//! Supports one folder, pack stream, and coder. Writers emit LZMA2. Readers -//! accept LZMA2 or LZMA, including encoded headers. BCJ, delta, AES, `PPMd`, -//! multiple folders, multiple coders, and coder graphs are unsupported. +//! Readers support any number of folders, each carrying a single LZMA2 or LZMA +//! coder over its own pack stream (non-solid archives map every file to its own +//! folder; solid archives keep several files in one). Exactly one folder decoder +//! is live at a time, so memory stays bounded regardless of folder count. +//! Writers still emit a single solid LZMA2 folder. BCJ, delta, AES, `PPMd`, +//! multiple coders per folder, and coder graphs are unsupported. use std::io::{Read, Seek, SeekFrom, Take, Write}; @@ -93,12 +96,13 @@ enum FolderCoder { Unsupported, } -/// The restricted single-folder description parsed from a `StreamsInfo`. +/// The single-coder description of one folder parsed from a `StreamsInfo`. An archive +/// carries a `Vec`; each folder decodes independently from its own pack stream. #[derive(Debug, Clone)] struct FolderInfo { - /// Absolute offset of the packed stream within the archive bytes. + /// Absolute offset of this folder's packed stream within the archive bytes. pack_offset: usize, - /// Length of the packed stream. + /// Length of this folder's packed stream. pack_size: usize, /// Uncompressed size of the whole folder. unpack_size: u64, @@ -119,6 +123,9 @@ struct FileRec { anti: bool, start_position: Option, has_stream: bool, + /// Which folder holds this file's content stream (`None` for empty-stream entries). + folder_index: Option, + /// Byte offset of this file's substream within its own folder's decoded output. stream_offset: usize, size: usize, } @@ -127,7 +134,7 @@ struct FileRec { #[derive(Debug, Default)] struct HeaderParser { files: Vec, - folder: Option, + folders: Vec, header_extensions: Vec, } @@ -135,24 +142,24 @@ impl HeaderParser { fn new() -> Self { Self { files: Vec::new(), - folder: None, + folders: Vec::new(), header_extensions: Vec::new(), } } /// Parses a plain `kHeader` body: optional main streams info, then files info. fn parse_header(&mut self, r: &mut ByteReader<'_>) -> Result<()> { - let mut folder: Option = None; + let mut folders: Vec = Vec::new(); loop { match r.u8()? { K_END => break, K_ARCHIVE_PROPERTIES => self.parse_archive_properties(r)?, - K_MAIN_STREAMS_INFO => folder = Some(parse_streams_info(r)?), - K_FILES_INFO => self.parse_files_info(r, folder.as_ref())?, + K_MAIN_STREAMS_INFO => folders = parse_streams_info(r)?, + K_FILES_INFO => self.parse_files_info(r, &folders)?, _ => return Err(HeaderError::Unsupported("7z: unsupported header property")), } } - self.folder = folder; + self.folders = folders; Ok(()) } @@ -175,11 +182,7 @@ impl HeaderParser { /// Parses `FilesInfo`, assembling [`FileRec`]s and assigning content-file windows in order. #[allow(clippy::too_many_lines, clippy::needless_range_loop)] - fn parse_files_info( - &mut self, - r: &mut ByteReader<'_>, - folder: Option<&FolderInfo>, - ) -> Result<()> { + fn parse_files_info(&mut self, r: &mut ByteReader<'_>, folders: &[FolderInfo]) -> Result<()> { let num_files = usize_of(r.number()?)?; if num_files as u64 > MAX_FILES { return Err(HeaderError::LimitExceeded("7z: too many files")); @@ -237,11 +240,23 @@ impl HeaderParser { )); } - // Assemble records. Content files consume substream windows in file order; empty-stream - // files are directories unless flagged as empty files. - let sizes = folder.map_or(&[][..], |f| f.substream_sizes.as_slice()); + // Assemble records. Content files consume substream windows in folder-then-file order: + // a flat list of `(folder_index, size)` pairs, one per substream across every folder in + // order, is drawn down as content files appear. The per-folder byte offset resets whenever + // the folder changes. Empty-stream files are directories unless flagged as empty files. + let flat: Vec<(usize, u64)> = folders + .iter() + .enumerate() + .flat_map(|(index, folder)| { + folder + .substream_sizes + .iter() + .map(move |&size| (index, size)) + }) + .collect(); let mut content_index = 0usize; let mut running = 0usize; + let mut current_folder: Option = None; let mut empty_index = 0usize; for i in 0..num_files { @@ -249,10 +264,15 @@ impl HeaderParser { let full_mode = modes.get(i).copied().flatten(); let has_stream = !empty_stream[i]; - let (kind, offset, size, is_anti) = if has_stream { - let size = usize_of(*sizes.get(content_index).ok_or(HeaderError::Malformed( + let (kind, folder_index, offset, size, is_anti) = if has_stream { + let (fi, raw_size) = *flat.get(content_index).ok_or(HeaderError::Malformed( "7z: content stream index out of range", - ))?)?; + ))?; + let size = usize_of(raw_size)?; + if current_folder != Some(fi) { + running = 0; + current_folder = Some(fi); + } let offset = running; running = running .checked_add(size) @@ -263,7 +283,7 @@ impl HeaderParser { } else { EntryKind::File }; - (kind, offset, size, false) + (kind, Some(fi), offset, size, false) } else { let is_empty_file = empty_file.get(empty_index).copied().unwrap_or(false); let is_anti = anti_empty.get(empty_index).copied().unwrap_or(false); @@ -273,7 +293,7 @@ impl HeaderParser { } else { EntryKind::Dir }; - (kind, 0, 0, is_anti) + (kind, None, 0, 0, is_anti) }; let mode = permission_bits(full_mode, kind); @@ -290,6 +310,7 @@ impl HeaderParser { anti: is_anti, start_position: start_positions.get(i).copied().flatten(), has_stream, + folder_index, stream_offset: offset, size, }); @@ -331,6 +352,14 @@ impl SevenInput { Self::Decoder(decoder) => decoder.source_ref(), } } + + /// Reclaims the raw source, dropping any live folder decoder (and its codec workspace). + fn into_source(self) -> R { + match self { + Self::Source(source) => source, + Self::Decoder(decoder) => (*decoder).into_inner(), + } + } } impl Read for SevenDecoder { @@ -352,15 +381,22 @@ enum SevenPhase { } /// Seek-capable 7z reader used by the opaque runtime dispatch. +/// +/// The reader owns the raw source through `input`; at most one folder decoder is live at a +/// time. `input` is `None` only transiently while [`SevenZSeekReader::set_active_folder`] +/// swaps the source between decoders, so the raw `R` is always reclaimable between calls. pub(crate) struct SevenZSeekReader { - input: SevenInput, + input: Option>, limits: Limits, archive_metadata: Option, files: Vec, - folder: Option, + folders: Vec, + /// The folder whose decoder is currently live (`None` before the first content stream). + active_folder: Option, next_file: usize, phase: SevenPhase, event_data: Vec, + /// Decoded byte position within the currently active folder (reset on every folder switch). decoded_position: usize, decoded_total: u64, } @@ -379,33 +415,17 @@ impl std::fmt::Debug for SevenZSeekReader { impl SevenZSeekReader { pub(crate) fn new(mut input: R, limits: Limits) -> core::result::Result { - let (archive_metadata, files, folder) = parse_seek_layout(&mut input, limits)?; - validate_seek_layout(&files, folder.as_ref(), limits)?; - let input = match folder.as_ref().map(|folder| folder.coder) { - None | Some(FolderCoder::Unsupported) => SevenInput::Source(input), - Some(coder) => { - let folder = folder - .as_ref() - .ok_or_else(|| seven_error(ErrorKind::Protocol, "folder state disappeared"))?; - input - .seek(SeekFrom::Start(u64::try_from(folder.pack_offset).map_err( - |_| seven_error(ErrorKind::Limit, "pack offset exceeds u64"), - )?)) - .map_err(StreamError::io)?; - let take = input.take( - u64::try_from(folder.pack_size) - .map_err(|_| seven_error(ErrorKind::Limit, "pack size exceeds u64"))?, - ); - let decoder = build_seven_decoder(take, coder, folder.unpack_size, limits)?; - SevenInput::Decoder(Box::new(decoder)) - }, - }; + let (archive_metadata, files, folders) = parse_seek_layout(&mut input, limits)?; + validate_seek_layout(&files, &folders, limits)?; + // Folder decoders are built lazily, one at a time, when the first file of each folder is + // reached. That keeps exactly one LZMA/LZMA2 workspace resident regardless of folder count. Ok(Self { - input, + input: Some(SevenInput::Source(input)), limits, archive_metadata: Some(archive_metadata), files, - folder, + folders, + active_folder: None, next_file: 0, phase: SevenPhase::Idle, event_data: Vec::with_capacity(64 * 1024), @@ -423,7 +443,7 @@ impl SevenZSeekReader { match self.phase { SevenPhase::Idle => { let Some(record) = self.files.get(self.next_file).cloned() else { - self.verify_folder_end()?; + self.finalize_active_folder()?; self.phase = SevenPhase::Done; return Ok(ReaderEvent::Done); }; @@ -496,15 +516,21 @@ impl SevenZSeekReader { } } + // `input` is only `None` transiently inside `set_active_folder`; it is always `Some` at any + // point a caller can observe the reader, so the fallbacks below are unreachable in practice. + #[allow(clippy::expect_used)] pub(crate) fn into_inner(self) -> R { - match self.input { - SevenInput::Source(source) => source, - SevenInput::Decoder(decoder) => (*decoder).into_inner(), - } + self.input + .map(SevenInput::into_source) + .expect("7z reader source is present outside a folder switch") } + #[allow(clippy::expect_used)] pub(crate) fn source_ref(&self) -> &R { - self.input.source_ref() + self.input + .as_ref() + .map(SevenInput::source_ref) + .expect("7z reader source is present outside a folder switch") } fn prepare_record( @@ -512,11 +538,17 @@ impl SevenZSeekReader { _index: usize, record: &FileRec, ) -> core::result::Result { - if record.has_stream && self.has_decoder() { + // Move the live decoder onto this file's folder before touching its bytes. `has_decoder` + // is false when the folder's coder is unsupported (its payload is then not decodable). + let has_decoder = match (record.has_stream, record.folder_index) { + (true, Some(folder_index)) => self.set_active_folder(folder_index)?, + _ => false, + }; + if record.has_stream && has_decoder { self.drain_to(record.stream_offset)?; } let mut link_target = None; - if record.has_stream && record.kind == EntryKind::Symlink && self.has_decoder() { + if record.has_stream && record.kind == EntryKind::Symlink && has_decoder { if self .limits .path_bytes() @@ -532,7 +564,7 @@ impl SevenZSeekReader { link_target = Some(ArchivePath::from_encoded(target, PathEncoding::Utf8)); self.phase = SevenPhase::EndEntry; } else if record.has_stream { - self.phase = if self.has_decoder() { + self.phase = if has_decoder { SevenPhase::Data { remaining: record.size, } @@ -618,10 +650,10 @@ impl SevenZSeekReader { amount: usize, ) -> core::result::Result { let count = match &mut self.input { - SevenInput::Decoder(decoder) => decoder + Some(SevenInput::Decoder(decoder)) => decoder .read(&mut self.event_data[..amount]) .map_err(seven_decode_error)?, - SevenInput::Source(_) => { + _ => { return Err(seven_error( ErrorKind::Unsupported, "payload coder is unsupported", @@ -663,14 +695,22 @@ impl SevenZSeekReader { Ok(()) } - fn verify_folder_end(&mut self) -> core::result::Result<(), StreamError> { - let Some(folder) = &self.folder else { + /// Verifies that the currently active folder decoded exactly to its declared size and + /// produced nothing past it. A no-op when no decoder is live (no folder yet, or an + /// unsupported coder). Called before switching folders and once the last file is consumed. + fn finalize_active_folder(&mut self) -> core::result::Result<(), StreamError> { + let Some(index) = self.active_folder else { return Ok(()); }; if !self.has_decoder() { return Ok(()); } - let expected = usize::try_from(folder.unpack_size) + let unpack_size = self + .folders + .get(index) + .ok_or_else(|| seven_error(ErrorKind::Protocol, "active folder index out of range"))? + .unpack_size; + let expected = usize::try_from(unpack_size) .map_err(|_| seven_error(ErrorKind::Limit, "folder size exceeds address space"))?; if self.decoded_position != expected { return Err(seven_error( @@ -688,14 +728,65 @@ impl SevenZSeekReader { Ok(()) } - const fn has_decoder(&self) -> bool { - matches!(&self.input, SevenInput::Decoder(_)) + /// Makes `folder_index` the active folder, building its decoder if the current one is a + /// different folder. The previous folder is finalized first, then its decoder dropped so + /// only one codec workspace is ever resident. Returns whether a decoder is now live + /// (`false` when the folder's coder is unsupported and its payload cannot be decoded). + fn set_active_folder( + &mut self, + folder_index: usize, + ) -> core::result::Result { + if self.active_folder == Some(folder_index) { + return Ok(self.has_decoder()); + } + self.finalize_active_folder()?; + let folder = self + .folders + .get(folder_index) + .ok_or_else(|| seven_error(ErrorKind::Protocol, "folder index out of range"))?; + let coder = folder.coder; + let pack_offset = folder.pack_offset; + let pack_size = folder.pack_size; + let unpack_size = folder.unpack_size; + let mut source = self.take_source()?; + self.decoded_position = 0; + self.active_folder = Some(folder_index); + if matches!(coder, FolderCoder::Unsupported) { + self.input = Some(SevenInput::Source(source)); + return Ok(false); + } + let offset = u64::try_from(pack_offset) + .map_err(|_| seven_error(ErrorKind::Limit, "pack offset exceeds u64"))?; + let size = u64::try_from(pack_size) + .map_err(|_| seven_error(ErrorKind::Limit, "pack size exceeds u64"))?; + if let Err(error) = source.seek(SeekFrom::Start(offset)) { + // Keep the source reachable so `into_inner`/`source_ref` stay valid after the error. + self.input = Some(SevenInput::Source(source)); + return Err(StreamError::io(error)); + } + let take = source.take(size); + let decoder = build_seven_decoder(take, coder, unpack_size, self.limits)?; + self.input = Some(SevenInput::Decoder(Box::new(decoder))); + Ok(true) + } + + /// Reclaims the raw source, dropping any live folder decoder. Leaves `input` as `None` + /// for the caller to restore before returning. + fn take_source(&mut self) -> core::result::Result { + self.input + .take() + .map(SevenInput::into_source) + .ok_or_else(|| seven_error(ErrorKind::Protocol, "7z reader source is unavailable")) + } + + fn has_decoder(&self) -> bool { + matches!(&self.input, Some(SevenInput::Decoder(_))) } fn decoder_mut(&mut self) -> Option<&mut SevenDecoder> { match &mut self.input { - SevenInput::Source(_) => None, - SevenInput::Decoder(decoder) => Some(decoder.as_mut()), + Some(SevenInput::Decoder(decoder)) => Some(decoder.as_mut()), + _ => None, } } } @@ -703,7 +794,8 @@ impl SevenZSeekReader { fn parse_seek_layout( input: &mut (impl Read + Seek), limits: Limits, -) -> core::result::Result<(ArchiveMetadata, Vec, Option), StreamError> { +) -> core::result::Result<(ArchiveMetadata, Vec, Vec), StreamError> { + #![allow(clippy::too_many_lines)] let image_length = input.seek(SeekFrom::End(0)).map_err(StreamError::io)?; input.seek(SeekFrom::Start(0)).map_err(StreamError::io)?; let mut signature = [0_u8; SIGNATURE_HEADER_SIZE]; @@ -722,7 +814,7 @@ fn parse_seek_layout( let header_size = u64_le(&signature, 20).map_err(seven_legacy_error)?; let header_crc = u32_le(&signature, 28).map_err(seven_legacy_error)?; if header_size == 0 { - return Ok((ArchiveMetadata::new(), Vec::new(), None)); + return Ok((ArchiveMetadata::new(), Vec::new(), Vec::new())); } if limits .metadata_bytes() @@ -766,14 +858,22 @@ fn parse_seek_layout( Some(K_HEADER) => header, Some(K_ENCODED_HEADER) => { let mut encoded = ByteReader::new(&header[1..]); - let folder = parse_streams_info(&mut encoded).map_err(seven_legacy_error)?; + let folders = parse_streams_info(&mut encoded).map_err(seven_legacy_error)?; + // The encoded next-header is itself decoded through the same folder machinery, but + // mainstream 7-Zip always writes it as a single folder; keep that restriction here. + let [folder] = folders.as_slice() else { + return Err(seven_error( + ErrorKind::Unsupported, + "encoded header must be a single folder", + )); + }; if matches!(folder.coder, FolderCoder::Unsupported) { return Err(seven_error( ErrorKind::Unsupported, "encoded-header coder is unsupported", )); } - decode_seek_header(input, &folder, limits, image_length)? + decode_seek_header(input, folder, limits, image_length)? }, _ => { return Err(seven_error( @@ -793,14 +893,14 @@ fn parse_seek_layout( parser .parse_header(&mut bytes) .map_err(seven_legacy_error)?; - if let Some(folder) = &parser.folder { + for folder in &parser.folders { validate_folder_range(folder, image_length)?; } let archive_metadata = parser .header_extensions .into_iter() .fold(ArchiveMetadata::new(), ArchiveMetadata::with_extension); - Ok((archive_metadata, parser.files, parser.folder)) + Ok((archive_metadata, parser.files, parser.folders)) } fn decode_seek_header( @@ -847,7 +947,7 @@ fn decode_seek_header( fn validate_seek_layout( files: &[FileRec], - folder: Option<&FolderInfo>, + folders: &[FolderInfo], limits: Limits, ) -> core::result::Result<(), StreamError> { if limits @@ -893,27 +993,31 @@ fn validate_seek_layout( "file metadata exceeds configured limit", )); } - if let Some(folder) = folder { - if limits - .decoded_total() - .is_some_and(|maximum| folder.unpack_size > maximum) - { - return Err(seven_error( - ErrorKind::Limit, - "folder output exceeds decoded-total limit", - )); - } - let total = files - .iter() - .filter(|file| file.has_stream) - .try_fold(0usize, |sum, file| sum.checked_add(file.size)) - .ok_or_else(|| seven_error(ErrorKind::Malformed, "substream size sum overflow"))?; - if u64::try_from(total).ok() != Some(folder.unpack_size) { - return Err(seven_error( - ErrorKind::Malformed, - "substream sizes do not equal folder size", - )); - } + // The sum of every folder's uncompressed output is what the reader will ever decode; bound it + // against the decoded-total budget, and confirm it equals the total of the file substreams. + let total_unpack = folders + .iter() + .try_fold(0u64, |sum, folder| sum.checked_add(folder.unpack_size)) + .ok_or_else(|| seven_error(ErrorKind::Malformed, "folder size sum overflow"))?; + if limits + .decoded_total() + .is_some_and(|maximum| total_unpack > maximum) + { + return Err(seven_error( + ErrorKind::Limit, + "folder output exceeds decoded-total limit", + )); + } + let total_content = files + .iter() + .filter(|file| file.has_stream) + .try_fold(0usize, |sum, file| sum.checked_add(file.size)) + .ok_or_else(|| seven_error(ErrorKind::Malformed, "substream size sum overflow"))?; + if u64::try_from(total_content).ok() != Some(total_unpack) { + return Err(seven_error( + ErrorKind::Malformed, + "substream sizes do not equal folder size", + )); } Ok(()) } @@ -1011,36 +1115,48 @@ fn seven_error(kind: ErrorKind, context: &'static str) -> StreamError { ) } -/// Parses a `StreamsInfo` (`PackInfo`, `UnpackInfo`, `SubStreamsInfo`) restricted to one pack, one folder, -/// and one LZMA2 coder. -fn parse_streams_info(r: &mut ByteReader<'_>) -> Result { +/// Parses a `StreamsInfo` (`PackInfo`, `UnpackInfo`, `SubStreamsInfo`) into one [`FolderInfo`] +/// per folder. Any number of folders is accepted, but each folder is restricted to a single +/// LZMA/LZMA2 coder — which means exactly one input (pack) stream per folder, so the pack-stream +/// count must equal the folder count. Pack streams lie back-to-back from the pack base position, +/// giving each folder its own absolute pack offset. +fn parse_streams_info(r: &mut ByteReader<'_>) -> Result> { + #![allow(clippy::too_many_lines)] let mut pack_pos = 0u64; - let mut pack_size: Option = None; - let mut coder: Option = None; - let mut unpack_size = 0u64; - let mut num_substreams = 1usize; - let mut substream_sizes: Option> = None; - // Whether the folder's UnpackInfo carried a CRC. Mainstream 7-Zip and `sevenz-rust2` do NOT put - // the folder CRC here — they store it only in SubStreamsInfo — so a single-substream folder still - // carries a digest in SubStreamsInfo. Tracking this is what makes real compressed headers parse. - let mut folder_has_crc = false; + let mut pack_sizes: Option> = None; + let mut coders: Vec = Vec::new(); + let mut unpack_sizes: Vec = Vec::new(); + let mut num_folders = 0usize; + // Per folder: whether its UnpackInfo already defined a CRC. Mainstream 7-Zip and `sevenz-rust2` + // do NOT put the folder CRC here — they store it only in SubStreamsInfo — so a single-substream + // folder still carries a digest there. Tracking this drives the SubStreamsInfo digest count. + let mut folder_has_crc: Vec = Vec::new(); + let mut substream_sizes: Option>> = None; loop { match r.u8()? { K_END => break, K_PACK_INFO => { pack_pos = r.number()?; - let num_pack = r.number()?; - if num_pack != 1 { - return Err(HeaderError::Unsupported( - "7z: only a single pack stream is supported", + let num_pack = usize_of(r.number()?)?; + if num_pack > r.remaining() { + return Err(HeaderError::Malformed( + "7z: pack-stream count exceeds header size", )); } loop { match r.u8()? { K_END => break, - K_SIZE => pack_size = Some(r.number()?), - K_CRC => read_digests(r, 1)?, + K_SIZE => { + let mut sizes = Vec::with_capacity(num_pack); + for _ in 0..num_pack { + sizes.push(r.number()?); + } + pack_sizes = Some(sizes); + }, + K_CRC => { + let _ = read_digests_defined(r, num_pack)?; + }, _ => { return Err(HeaderError::Unsupported( "7z: unsupported pack-info property", @@ -1053,25 +1169,33 @@ fn parse_streams_info(r: &mut ByteReader<'_>) -> Result { if r.u8()? != K_FOLDER { return Err(HeaderError::Malformed("7z: unpack info missing kFolder")); } - if r.number()? != 1 { - return Err(HeaderError::Unsupported( - "7z: only a single folder is supported", - )); - } + num_folders = usize_of(r.number()?)?; if r.u8()? != 0 { return Err(HeaderError::Unsupported("7z: external folder definitions")); } - coder = Some(read_folder(r)?); + if num_folders > r.remaining() { + return Err(HeaderError::Malformed( + "7z: folder count exceeds header size", + )); + } + coders = Vec::with_capacity(num_folders); + for _ in 0..num_folders { + coders.push(read_folder(r)?); + } if r.u8()? != K_CODERS_UNPACK_SIZE { return Err(HeaderError::Malformed("7z: missing coders-unpack-size")); } - unpack_size = r.number()?; + // One output stream per single-coder folder, so one unpack size per folder. + unpack_sizes = Vec::with_capacity(num_folders); + for _ in 0..num_folders { + unpack_sizes.push(r.number()?); + } + folder_has_crc = vec![false; num_folders]; loop { match r.u8()? { K_END => break, K_CRC => { - folder_has_crc = true; - read_digests(r, 1)?; + folder_has_crc = read_digests_defined(r, num_folders)?; }, _ => { return Err(HeaderError::Unsupported( @@ -1082,9 +1206,7 @@ fn parse_streams_info(r: &mut ByteReader<'_>) -> Result { } }, K_SUBSTREAMS_INFO => { - let (n, sizes) = parse_substreams_info(r, unpack_size, folder_has_crc)?; - num_substreams = n; - substream_sizes = Some(sizes); + substream_sizes = Some(parse_substreams_info(r, &unpack_sizes, &folder_has_crc)?); }, _ => { return Err(HeaderError::Unsupported( @@ -1094,24 +1216,49 @@ fn parse_streams_info(r: &mut ByteReader<'_>) -> Result { } } - let pack_size = pack_size.ok_or(HeaderError::Malformed("7z: missing pack size"))?; - let coder = coder.ok_or(HeaderError::Malformed("7z: missing coder properties"))?; + if num_folders == 0 { + return Err(HeaderError::Malformed("7z: missing folder definitions")); + } + if coders.len() != num_folders || unpack_sizes.len() != num_folders { + return Err(HeaderError::Malformed("7z: inconsistent folder table")); + } + let pack_sizes = pack_sizes.ok_or(HeaderError::Malformed("7z: missing pack sizes"))?; + // A single coder consumes exactly one pack stream, so folders and pack streams pair up 1:1. + if pack_sizes.len() != num_folders { + return Err(HeaderError::Unsupported( + "7z: pack-stream count must equal folder count", + )); + } let substream_sizes = match substream_sizes { - Some(s) => s, - None => vec![unpack_size], + Some(sizes) => sizes, + None => unpack_sizes.iter().map(|&size| vec![size]).collect(), }; - let _ = num_substreams; - let pack_offset = SIGNATURE_HEADER_SIZE + if substream_sizes.len() != num_folders { + return Err(HeaderError::Malformed( + "7z: substream folder-count mismatch", + )); + } + + let base = SIGNATURE_HEADER_SIZE .checked_add(usize_of(pack_pos)?) .ok_or(HeaderError::Malformed("7z: pack offset overflow"))?; - - Ok(FolderInfo { - pack_offset, - pack_size: usize_of(pack_size)?, - unpack_size, - coder, - substream_sizes, - }) + let mut folders = Vec::with_capacity(num_folders); + let mut running_pack = base; + for index in 0..num_folders { + let pack_size = usize_of(pack_sizes[index])?; + let pack_offset = running_pack; + running_pack = running_pack + .checked_add(pack_size) + .ok_or(HeaderError::Malformed("7z: pack offset overflow"))?; + folders.push(FolderInfo { + pack_offset, + pack_size, + unpack_size: unpack_sizes[index], + coder: coders[index], + substream_sizes: substream_sizes[index].clone(), + }); + } + Ok(folders) } /// Parses a single folder definition, returning its coder (LZMA2 or plain LZMA) and properties. @@ -1162,44 +1309,73 @@ fn read_folder(r: &mut ByteReader<'_>) -> Result { } } -/// Parses a `SubStreamsInfo` block for the single folder, returning `(num_substreams, sizes)`. +/// Parses a `SubStreamsInfo` block covering every folder, returning per-folder substream sizes. /// -/// `folder_has_crc` says whether the folder's `UnpackInfo` already defined a CRC: a single-substream -/// folder repeats no digest here only when it did, matching the 7z rule -/// `numDigests = Σ folders where (numSubstreams != 1 || !folderCrcDefined)`. +/// `folder_unpack[i]` is folder `i`'s uncompressed size and `folder_has_crc[i]` whether its +/// `UnpackInfo` already defined a CRC. The digest count follows the 7z rule +/// `numDigests = Σ_folders (numSubstreams(i) == 1 && folderCrcDefined(i) ? 0 : numSubstreams(i))`, +/// and single-substream folders omit their stored size (it equals the folder size). fn parse_substreams_info( r: &mut ByteReader<'_>, - folder_unpack: u64, - folder_has_crc: bool, -) -> Result<(usize, Vec)> { - let mut num = 1usize; - let mut sizes: Vec = Vec::new(); - let mut have_sizes = false; + folder_unpack: &[u64], + folder_has_crc: &[bool], +) -> Result>> { + let num_folders = folder_unpack.len(); + let mut counts: Vec = vec![1; num_folders]; + let mut sizes: Option>> = None; loop { match r.u8()? { K_END => break, - K_NUM_UNPACK_STREAM => num = usize_of(r.number()?)?, + K_NUM_UNPACK_STREAM => { + let mut parsed = Vec::with_capacity(num_folders); + for _ in 0..num_folders { + parsed.push(usize_of(r.number()?)?); + } + counts = parsed; + }, K_SIZE => { - let mut sum = 0u64; - for _ in 0..num.saturating_sub(1) { - let s = r.number()?; - sum = sum - .checked_add(s) - .ok_or(HeaderError::Malformed("7z: substream size overflow"))?; - sizes.push(s); + let mut per_folder = Vec::with_capacity(num_folders); + for (index, &folder_size) in folder_unpack.iter().enumerate() { + let count = counts[index]; + if count == 0 { + per_folder.push(Vec::new()); + continue; + } + // Only (count - 1) sizes are stored; each needs at least one header byte. + if count.saturating_sub(1) > r.remaining() { + return Err(HeaderError::Malformed( + "7z: substream count exceeds header size", + )); + } + let mut folder_sizes = Vec::with_capacity(count); + let mut sum = 0u64; + for _ in 0..count - 1 { + let size = r.number()?; + sum = sum + .checked_add(size) + .ok_or(HeaderError::Malformed("7z: substream size overflow"))?; + folder_sizes.push(size); + } + let last = folder_size + .checked_sub(sum) + .ok_or(HeaderError::Malformed("7z: substream sizes exceed folder"))?; + folder_sizes.push(last); + per_folder.push(folder_sizes); } - let last = folder_unpack - .checked_sub(sum) - .ok_or(HeaderError::Malformed("7z: substream sizes exceed folder"))?; - sizes.push(last); - have_sizes = true; + sizes = Some(per_folder); }, K_CRC => { - // A digest is present for every substream except the single-substream case whose CRC - // is already defined on the folder (then it is not repeated). - let unknown = if num == 1 && folder_has_crc { 0 } else { num }; - read_digests(r, unknown)?; + // A digest is present for every substream except a single-substream folder whose + // CRC is already defined on the folder (that one is not repeated here). + let unknown = counts + .iter() + .enumerate() + .fold(0usize, |total, (index, &n)| { + let already = n == 1 && folder_has_crc.get(index).copied().unwrap_or(false); + total.saturating_add(if already { 0 } else { n }) + }); + let _ = read_digests_defined(r, unknown)?; }, _ => { return Err(HeaderError::Unsupported( @@ -1209,35 +1385,51 @@ fn parse_substreams_info( } } - if !have_sizes { - if num == 1 { - sizes = vec![folder_unpack]; - } else { - return Err(HeaderError::Malformed("7z: missing substream sizes")); + let sizes = if let Some(sizes) = sizes { + sizes + } else { + // No kSize: every folder must be single-substream, taking the whole folder as its size. + let mut per_folder = Vec::with_capacity(num_folders); + for (index, &folder_size) in folder_unpack.iter().enumerate() { + match counts[index] { + 0 => per_folder.push(Vec::new()), + 1 => per_folder.push(vec![folder_size]), + _ => return Err(HeaderError::Malformed("7z: missing substream sizes")), + } + } + per_folder + }; + for (index, folder_sizes) in sizes.iter().enumerate() { + if folder_sizes.len() != counts[index] { + return Err(HeaderError::Malformed("7z: substream count mismatch")); } } - if sizes.len() != num { - return Err(HeaderError::Malformed("7z: substream count mismatch")); - } - Ok((num, sizes)) + Ok(sizes) } /// Reads a `Digests` structure (`AllAreDefined` byte + optional bit vector + one CRC per defined -/// item), discarding the values after bounds-checking them. -fn read_digests(r: &mut ByteReader<'_>, count: usize) -> Result<()> { +/// item), returning the per-item "defined" flags after bounds-checking the CRCs. +fn read_digests_defined(r: &mut ByteReader<'_>, count: usize) -> Result> { if count == 0 { - return Ok(()); + return Ok(Vec::new()); + } + if count > r.remaining() { + return Err(HeaderError::Malformed( + "7z: digest count exceeds header size", + )); } let all_defined = r.u8()?; let defined = if all_defined != 0 { - count + vec![true; count] } else { - r.bit_vector(count)?.iter().filter(|&&b| b).count() + r.bit_vector(count)? }; - for _ in 0..defined { - let _ = r.u32()?; + for &is_defined in &defined { + if is_defined { + let _ = r.u32()?; + } } - Ok(()) + Ok(defined) } /// Parses the `kName` property: an external byte (must be 0) then null-terminated UTF-16LE names. diff --git a/libarchive_oxide/tests/sevenz_differential.rs b/libarchive_oxide/tests/sevenz_differential.rs index 32d940d..58dba42 100644 --- a/libarchive_oxide/tests/sevenz_differential.rs +++ b/libarchive_oxide/tests/sevenz_differential.rs @@ -155,6 +155,78 @@ fn arca_reads_sevenz_rust2_lzma_folder() { assert_eq!(got, expected); } +/// Non-solid archives put every file in its own folder (its own pack stream and coder). `sevenz-rust2` +/// produces exactly that when each entry is pushed with the singular `push_archive_entry`. arca must +/// walk the resulting `Vec`, activating one decoder at a time, and reproduce every file's +/// bytes — the core of RM-303 step 1. Directory and empty-file entries (which carry no folder) are +/// interleaved to confirm the file->folder mapping skips them correctly. +#[test] +fn arca_reads_sevenz_rust2_multi_folder() { + let a = b"alpha folder payload\n".to_vec(); + let b = b"beta folder payload, repeated for compressibility\n".repeat(30); + let c = b"gamma folder payload with different bytes\n".repeat(12); + + let mut w = SevenWriter::new(Cursor::new(Vec::new())).unwrap(); + // A directory and an empty file carry no content stream, so they never open a folder. + w.push_archive_entry::<&[u8]>(ArchiveEntry::new_directory("pkg"), None) + .unwrap(); + w.push_archive_entry(ArchiveEntry::new_file("pkg/a.txt"), Some(a.as_slice())) + .unwrap(); + w.push_archive_entry(ArchiveEntry::new_file("pkg/b.txt"), Some(b.as_slice())) + .unwrap(); + w.push_archive_entry::<&[u8]>(ArchiveEntry::new_file("pkg/empty.txt"), None) + .unwrap(); + w.push_archive_entry(ArchiveEntry::new_file("pkg/c.txt"), Some(c.as_slice())) + .unwrap(); + let bytes = w.finish().unwrap().into_inner(); + + // Three distinct content files => three separate folders/blocks (non-solid). + let reader = SevenReader::new(Cursor::new(bytes.clone()), Password::empty()) + .expect("sevenz-rust2 opens its own multi-folder archive"); + assert!( + !reader.archive().is_solid, + "push_archive_entry should produce a non-solid (multi-folder) archive" + ); + assert!( + reader.archive().blocks.len() >= 3, + "expected one folder per content file, got {} folders", + reader.archive().blocks.len() + ); + + let got = read_with_arca(&bytes); + let expected = vec![ + EntryShape::new(b"pkg".to_vec(), EntryKind::Dir, Vec::new()), + EntryShape::new(b"pkg/a.txt".to_vec(), EntryKind::File, a.clone()), + EntryShape::new(b"pkg/b.txt".to_vec(), EntryKind::File, b.clone()), + EntryShape::new(b"pkg/empty.txt".to_vec(), EntryKind::File, Vec::new()), + EntryShape::new(b"pkg/c.txt".to_vec(), EntryKind::File, c.clone()), + ]; + assert_eq!(got, expected); +} + +/// The same non-solid multi-folder shape, but with the plain-LZMA coder: each file is its own +/// folder, so arca must tear down one `LzmaReader` and seek+build the next between entries. +#[test] +fn arca_reads_sevenz_rust2_multi_folder_lzma() { + let a = b"first lzma folder\n".repeat(8); + let b = b"second lzma folder, a good deal longer than the first\n".repeat(25); + + let mut w = SevenWriter::new(Cursor::new(Vec::new())).unwrap(); + w.set_content_methods(vec![EncoderConfiguration::new(EncoderMethod::LZMA)]); + w.push_archive_entry(ArchiveEntry::new_file("a.bin"), Some(a.as_slice())) + .unwrap(); + w.push_archive_entry(ArchiveEntry::new_file("b.bin"), Some(b.as_slice())) + .unwrap(); + let bytes = w.finish().unwrap().into_inner(); + + let got = read_with_arca(&bytes); + let expected = vec![ + EntryShape::new(b"a.bin".to_vec(), EntryKind::File, a.clone()), + EntryShape::new(b"b.bin".to_vec(), EntryKind::File, b.clone()), + ]; + assert_eq!(got, expected); +} + /// A compressed (`kEncodedHeader`) next header is what mainstream 7-Zip / `sevenz-rust2` emit once an /// archive carries more than a trivial number of entries: `sevenz-rust2` LZMA-compresses the header /// whenever that shrinks it. Enough long, repetitive names force that path, so this archive lands on From b9dd13c799f789a688c8d4bf1274cd4386d57d3b Mon Sep 17 00:00:00 2001 From: Yasunobu <42543015+P4suta@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:59:33 +0900 Subject: [PATCH 2/8] feat(7z): general coder-graph parse + linear-chain decode infra [RM-303] Landing step 2 of the 7z coder-graph work. Pure infra: no behavior change for existing archives (differential/interop tests unchanged). Parser: - Replace the single-coder FolderCoder model with a spec-complete coder graph (Coder/BindPair/Folder). read_folder now parses any number of coders, bind pairs, and packed-stream indices; read_coder handles complex (multi-in/out) coders and coders without attributes; classify_method keeps only LZMA/LZMA2 decodable, every other id lists as Unsupported. - parse_streams_info reads one CodersUnpackSize per output stream across all folders, assigns each folder its own back-to-back pack streams (multi-pack aware), and derives each folder's final-output (unpack) size. - resolve_folder computes a topological decode order via post-order DFS with in-progress cycle detection, and rejects stream overlaps (an input fed by more than one source or none, an output bound twice) as typed Malformed. Decode: - SevenStage: a recursive, statically dispatched Read chain (Source pack stream wrapped by each coder's reader). build_folder_reader folds decode_order into the chain; only linear LZMA/LZMA2 folders over one pack stream are built, so BCJ2/PPMd/multi-in-out list but report Unsupported at build. Still exactly one decode chain live at a time (bounded memory). Codec infra: - Generalize PipelineRead into codec_read::CodecReader; filtered_io reuses it for zstd/LZ4 via a thin PipelineRead alias, and PipelineCodec now implements Codec. Verified: build -p libarchive_oxide with sevenz, "sevenz aes", --no-default-features --features sevenz, and default (zstd/lz4); xtask codec-policy (portable stays C/FFI-free) and no-dyn (static dispatch) pass; clippy clean; sevenz_differential, interop_foundation, and fuzz_replay green. #![forbid(unsafe_code)] holds. Cross/qemu/macOS matrices deferred to CI. Co-Authored-By: Claude Opus 4.8 (1M context) --- libarchive_oxide/src/codec_read.rs | 152 +++++++ libarchive_oxide/src/filtered_io.rs | 151 +------ libarchive_oxide/src/lib.rs | 2 + libarchive_oxide/src/pipeline_codec.rs | 13 + libarchive_oxide/src/sevenz.rs | 592 ++++++++++++++++++++----- 5 files changed, 682 insertions(+), 228 deletions(-) create mode 100644 libarchive_oxide/src/codec_read.rs diff --git a/libarchive_oxide/src/codec_read.rs b/libarchive_oxide/src/codec_read.rs new file mode 100644 index 0000000..3fc5045 --- /dev/null +++ b/libarchive_oxide/src/codec_read.rs @@ -0,0 +1,152 @@ +// SPDX-FileCopyrightText: 2026 libarchive_oxide contributors +// +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Generic bounded `Read` adapter over any sans-I/O [`Codec`]. +//! +//! [`CodecReader`] drives a caller-supplied [`Codec`] against an inner byte +//! source, retaining neither the compressed input nor the decoded output beyond +//! one fixed-size staging buffer. It is the shared engine behind the outer +//! filter readers (zstd, LZ4, …) and any future coder that speaks the sans-I/O +//! [`Codec`] protocol. + +use std::io::{self, Read}; + +use libarchive_oxide_core::{ArchiveError, Codec, CodecStatus, EndOfInput, ErrorKind}; + +/// Staging-buffer size for the compressed side of a [`CodecReader`]. +pub(crate) const BUFFER: usize = 64 * 1024; + +/// A bounded streaming reader that decodes `input` through the sans-I/O `decoder`. +/// +/// Generic over the inner reader `Inner` and the codec `C`, so a single +/// implementation serves every codec that implements [`Codec`]. `name` labels +/// the codec in error messages. +pub(crate) struct CodecReader { + input: Inner, + decoder: C, + name: &'static str, + buffer: Vec, + start: usize, + end: usize, + eof: bool, + done: bool, + failed: bool, +} + +impl CodecReader { + /// Wraps `input`, decoding it through `decoder`. `name` labels the codec in errors. + pub(crate) fn new(input: Inner, decoder: C, name: &'static str) -> Self { + Self { + input, + decoder, + name, + buffer: vec![0; BUFFER], + start: 0, + end: 0, + eof: false, + done: false, + failed: false, + } + } + + /// Reclaims the inner reader at its current physical position. + pub(crate) fn into_inner(self) -> Inner { + self.input + } + + fn fill(&mut self) -> io::Result<()> { + if self.start != 0 { + self.buffer.copy_within(self.start..self.end, 0); + self.end -= self.start; + self.start = 0; + } + if self.end == self.buffer.len() || self.eof { + return Ok(()); + } + let read = self.input.read(&mut self.buffer[self.end..])?; + if read == 0 { + self.eof = true; + } else { + self.end += read; + } + Ok(()) + } + + fn fail(&mut self, error: io::Error) -> io::Result { + self.failed = true; + Err(error) + } +} + +impl Read for CodecReader { + fn read(&mut self, output: &mut [u8]) -> io::Result { + if output.is_empty() || self.done { + return Ok(0); + } + if self.failed { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("{} reader is in a failed state", self.name), + )); + } + loop { + if self.start == self.end && !self.eof { + self.fill()?; + } + let input_length = self.end - self.start; + let end = if self.eof { + EndOfInput::End + } else { + EndOfInput::More + }; + let step = match self + .decoder + .process(&self.buffer[self.start..self.end], output, end) + .and_then(|step| step.validate(input_length, output.len())) + { + Ok(step) => step, + Err(error) => return self.fail(codec_archive_io(error)), + }; + self.start += step.consumed; + if step.produced != 0 { + return Ok(step.produced); + } + match step.status { + CodecStatus::Done => { + self.done = true; + return Ok(0); + }, + CodecStatus::NeedInput if self.start == self.end && !self.eof => { + self.fill()?; + }, + CodecStatus::NeedOutput if step.consumed != 0 => {}, + CodecStatus::NeedInput if self.eof => { + return self.fail(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "{} decoder requested input after the source ended", + self.name + ), + )); + }, + _ => { + return self.fail(io::Error::new( + io::ErrorKind::InvalidData, + format!("{} reader made no progress", self.name), + )); + }, + } + } + } +} + +/// Maps a codec [`ArchiveError`] onto an [`io::Error`], preserving the failure class. +pub(crate) fn codec_archive_io(error: ArchiveError) -> io::Error { + let kind = match error.kind() { + ErrorKind::Limit => io::ErrorKind::OutOfMemory, + ErrorKind::Unsupported => io::ErrorKind::Unsupported, + _ => io::ErrorKind::InvalidData, + }; + io::Error::new(kind, error) +} diff --git a/libarchive_oxide/src/filtered_io.rs b/libarchive_oxide/src/filtered_io.rs index 81faa9d..f2da0a6 100644 --- a/libarchive_oxide/src/filtered_io.rs +++ b/libarchive_oxide/src/filtered_io.rs @@ -14,12 +14,34 @@ use libarchive_oxide_core::Codec; use libarchive_oxide_core::filter::FilterId; use libarchive_oxide_core::{ArchiveError, CodecStatus, EndOfInput, ErrorKind, Limits}; +#[cfg(any(feature = "zstd", feature = "lz4"))] +use crate::codec_read::CodecReader; #[cfg(not(feature = "native-codecs"))] use crate::filter::gzip::GzipEncoder; use crate::pipeline_codec::PipelineCodec; const BUFFER: usize = 64 * 1024; +/// A bounded outer-filter reader: the shared [`CodecReader`] engine driving a [`PipelineCodec`]. +#[cfg(any(feature = "zstd", feature = "lz4"))] +type PipelineRead = CodecReader; + +/// Builds a [`PipelineRead`] for an outer filter, constructing its [`PipelineCodec`] first. +#[cfg(any(feature = "zstd", feature = "lz4"))] +fn pipeline_reader( + input: R, + filter: FilterId, + limits: Limits, +) -> io::Result> { + let name = match filter { + FilterId::Zstd => "zstd", + FilterId::Lz4 => "LZ4", + _ => "codec", + }; + let codec = PipelineCodec::new(filter, limits).map_err(codec_archive_io)?; + Ok(CodecReader::new(input, codec, name)) +} + /// Auto-detecting streaming input filter. /// /// gzip uses the common sans-I/O codec. The other variants use bounded @@ -96,7 +118,7 @@ impl FilterReader { if available.starts_with(&[0x28, 0xb5, 0x2f, 0xfd]) { #[cfg(feature = "zstd")] { - return PipelineRead::new(input, FilterId::Zstd, limits) + return pipeline_reader(input, FilterId::Zstd, limits) .map(Box::new) .map(FilterReaderInner::Zstd) .map(|inner| Self { @@ -130,7 +152,7 @@ impl FilterReader { } if available.starts_with(&[0x04, 0x22, 0x4d, 0x18]) { #[cfg(feature = "lz4")] - return PipelineRead::new(input, FilterId::Lz4, limits) + return pipeline_reader(input, FilterId::Lz4, limits) .map(Box::new) .map(FilterReaderInner::Lz4) .map(|inner| Self { @@ -192,131 +214,6 @@ impl Read for PrefixReader { } } -#[cfg(any(feature = "zstd", feature = "lz4"))] -struct PipelineRead { - input: R, - decoder: PipelineCodec, - filter_name: &'static str, - buffer: Vec, - start: usize, - end: usize, - eof: bool, - done: bool, - failed: bool, -} - -#[cfg(any(feature = "zstd", feature = "lz4"))] -impl PipelineRead { - fn new(input: R, filter: FilterId, limits: Limits) -> io::Result { - let filter_name = match filter { - FilterId::Zstd => "zstd", - FilterId::Lz4 => "LZ4", - _ => "codec", - }; - Ok(Self { - input, - decoder: PipelineCodec::new(filter, limits).map_err(codec_archive_io)?, - filter_name, - buffer: vec![0; BUFFER], - start: 0, - end: 0, - eof: false, - done: false, - failed: false, - }) - } - - fn into_inner(self) -> R { - self.input - } - - fn fill(&mut self) -> io::Result<()> { - if self.start != 0 { - self.buffer.copy_within(self.start..self.end, 0); - self.end -= self.start; - self.start = 0; - } - if self.end == self.buffer.len() || self.eof { - return Ok(()); - } - let read = self.input.read(&mut self.buffer[self.end..])?; - if read == 0 { - self.eof = true; - } else { - self.end += read; - } - Ok(()) - } - - fn fail(&mut self, error: io::Error) -> io::Result { - self.failed = true; - Err(error) - } -} - -#[cfg(any(feature = "zstd", feature = "lz4"))] -impl Read for PipelineRead { - fn read(&mut self, output: &mut [u8]) -> io::Result { - if output.is_empty() || self.done { - return Ok(0); - } - if self.failed { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("{} reader is in a failed state", self.filter_name), - )); - } - loop { - if self.start == self.end && !self.eof { - self.fill()?; - } - let input_length = self.end - self.start; - let end = if self.eof { - EndOfInput::End - } else { - EndOfInput::More - }; - let step = match self - .decoder - .process(&self.buffer[self.start..self.end], output, end) - .and_then(|step| step.validate(input_length, output.len())) - { - Ok(step) => step, - Err(error) => return self.fail(codec_archive_io(error)), - }; - self.start += step.consumed; - if step.produced != 0 { - return Ok(step.produced); - } - match step.status { - CodecStatus::Done => { - self.done = true; - return Ok(0); - }, - CodecStatus::NeedInput if self.start == self.end && !self.eof => { - self.fill()?; - }, - CodecStatus::NeedOutput if step.consumed != 0 => {}, - CodecStatus::NeedInput if self.eof => { - return self.fail(io::Error::new( - io::ErrorKind::InvalidData, - format!( - "{} decoder requested input after the source ended", - self.filter_name - ), - )); - }, - _ => { - return self.fail(io::Error::new( - io::ErrorKind::InvalidData, - format!("{} reader made no progress", self.filter_name), - )); - }, - } - } - } -} - #[cfg(feature = "xz")] struct XzRead { input: R, diff --git a/libarchive_oxide/src/lib.rs b/libarchive_oxide/src/lib.rs index 4f22b54..76421bb 100644 --- a/libarchive_oxide/src/lib.rs +++ b/libarchive_oxide/src/lib.rs @@ -32,6 +32,8 @@ pub mod async_seek; #[cfg(feature = "async")] pub mod async_stream; mod cab; +#[cfg(any(feature = "zstd", feature = "lz4"))] +mod codec_read; pub mod create; pub mod engine; pub mod extractor; diff --git a/libarchive_oxide/src/pipeline_codec.rs b/libarchive_oxide/src/pipeline_codec.rs index 059f5fb..b18ca85 100644 --- a/libarchive_oxide/src/pipeline_codec.rs +++ b/libarchive_oxide/src/pipeline_codec.rs @@ -174,6 +174,19 @@ impl PipelineCodec { } } +impl Codec for PipelineCodec { + fn process( + &mut self, + input: &[u8], + output: &mut [u8], + end: EndOfInput, + ) -> Result { + // Delegates to the inherent method (inherent resolution wins, so this does not recurse), + // letting `PipelineCodec` drive the generic `CodecReader<_, PipelineCodec>`. + PipelineCodec::process(self, input, output, end) + } +} + #[cfg(all(feature = "zstd", feature = "native-codecs"))] fn native_zstd_decoder(limits: Limits) -> Result { let Some(memory_limit) = limits.codec_memory() else { diff --git a/libarchive_oxide/src/sevenz.rs b/libarchive_oxide/src/sevenz.rs index a0f3841..71714b1 100644 --- a/libarchive_oxide/src/sevenz.rs +++ b/libarchive_oxide/src/sevenz.rs @@ -4,12 +4,14 @@ //! Seek-capable, payload-streaming 7z reader and writer. //! -//! Readers support any number of folders, each carrying a single LZMA2 or LZMA -//! coder over its own pack stream (non-solid archives map every file to its own -//! folder; solid archives keep several files in one). Exactly one folder decoder -//! is live at a time, so memory stays bounded regardless of folder count. -//! Writers still emit a single solid LZMA2 folder. BCJ, delta, AES, `PPMd`, -//! multiple coders per folder, and coder graphs are unsupported. +//! Readers parse every folder's full coder graph (any number of coders, bind +//! pairs, and packed streams) so listing works for any archive. Decoding is +//! limited to linear LZMA2/LZMA chains over a single pack stream: a folder built +//! that way streams normally, while richer graphs (BCJ2, `PPMd`, multi-coder +//! filter chains) list but report `Unsupported` on extraction. Exactly one +//! folder decode chain is live at a time, so memory stays bounded regardless of +//! folder count. Writers still emit a single solid LZMA2 folder. BCJ, delta, and +//! AES decode are not yet implemented. use std::io::{Read, Seek, SeekFrom, Take, Write}; @@ -84,10 +86,12 @@ const WRITER_PRESET: u32 = 6; // Parsed structures // ════════════════════════════════════════════════════════════════════════════════════════════════ -/// The single-coder codec of a folder. Reading supports both LZMA2 (what this crate writes) and plain -/// LZMA (what 7-Zip and `sevenz-rust2` use for compressed/encoded headers and folders). +/// One coder's decode method as parsed from a folder graph. Decoding supports LZMA2 (what this +/// crate writes) and plain LZMA (what 7-Zip and `sevenz-rust2` use for compressed/encoded headers +/// and folders). Every other method id (BCJ, delta, `PPMd`, Deflate, `BZip2`, Zstd, BCJ2, …) parses +/// into [`CoderMethod::Unsupported`] so listing works, but its payload cannot be decoded here. #[derive(Debug, Clone, Copy)] -enum FolderCoder { +enum CoderMethod { /// LZMA2, carrying its one-byte dictionary-size property. Lzma2 { dict_prop: u8 }, /// LZMA (v1), carrying its 5 property bytes: `lc/lp/pb` byte + little-endian `u32` dict size. @@ -96,22 +100,125 @@ enum FolderCoder { Unsupported, } -/// The single-coder description of one folder parsed from a `StreamsInfo`. An archive -/// carries a `Vec`; each folder decodes independently from its own pack stream. +/// One coder in a folder's graph, with its input/output stream arity. +#[derive(Debug, Clone, Copy)] +struct Coder { + method: CoderMethod, + /// Number of input streams the coder consumes. + num_in: usize, + /// Number of output streams the coder produces. + num_out: usize, +} + +/// A bind pair links one coder's output stream to another coder's input stream, both addressed by +/// global stream index within the folder. +#[derive(Debug, Clone, Copy)] +struct BindPair { + in_index: usize, + out_index: usize, +} + +/// A parsed folder coder graph. Spec-complete: it may describe more coders, bind pairs, or packed +/// streams than the linear LZMA/LZMA2 chains this crate can decode, so listing always works. +#[derive(Debug, Clone)] +struct Folder { + coders: Vec, + bind_pairs: Vec, + /// Global input-stream indices fed directly by pack streams, in pack-stream order. + packed_indices: Vec, +} + +impl Folder { + fn total_in(&self) -> usize { + self.coders.iter().map(|coder| coder.num_in).sum() + } + + fn total_out(&self) -> usize { + self.coders.iter().map(|coder| coder.num_out).sum() + } + + /// First global input-stream index owned by coder `index`. + fn in_base(&self, index: usize) -> usize { + self.coders[..index].iter().map(|coder| coder.num_in).sum() + } + + /// First global output-stream index owned by coder `index`. + fn out_base(&self, index: usize) -> usize { + self.coders[..index].iter().map(|coder| coder.num_out).sum() + } + + /// The coder that owns global output stream `out`. + fn coder_of_out(&self, out: usize) -> Option { + let mut base = 0; + for (index, coder) in self.coders.iter().enumerate() { + base += coder.num_out; + if out < base { + return Some(index); + } + } + None + } + + /// The single output stream not consumed by a bind pair — the folder's result. + fn final_out_index(&self) -> Result { + (0..self.total_out()) + .find(|out| !self.bind_pairs.iter().any(|pair| pair.out_index == *out)) + .ok_or(HeaderError::Malformed( + "7z: folder has no final output stream", + )) + } + + /// True when the graph is a linear chain of single-in/single-out coders fed by exactly one + /// pack stream — the shape [`build_folder_reader`] can fold into a decode chain. + fn is_linear_chain(&self) -> bool { + self.packed_indices.len() == 1 + && self + .coders + .iter() + .all(|coder| coder.num_in == 1 && coder.num_out == 1) + } +} + +/// One folder's fully-resolved decode description: its coder graph, pack-stream layout, output +/// sizes, and the topological decode order computed by [`resolve_folder`]. An archive carries a +/// `Vec`; each folder decodes independently from its own pack stream(s). #[derive(Debug, Clone)] struct FolderInfo { - /// Absolute offset of this folder's packed stream within the archive bytes. + /// The parsed coder graph (spec-complete, possibly beyond what can be decoded). + graph: Folder, + /// Absolute offset of this folder's first pack stream within the archive bytes. pack_offset: usize, - /// Length of this folder's packed stream. - pack_size: usize, - /// Uncompressed size of the whole folder. + /// This folder's pack-stream sizes, in `graph.packed_indices` order. + pack_sizes: Vec, + /// Uncompressed size of the folder's final output stream (what a reader decodes from it). unpack_size: u64, - /// The folder's single coder and its properties. - coder: FolderCoder, + /// Per global output-stream uncompressed sizes, in coder order. + output_sizes: Vec, + /// Coder indices in execution order (dependencies first), from [`resolve_folder`]. + decode_order: Vec, /// Per-substream (per content file) uncompressed sizes, in file order. substream_sizes: Vec, } +impl FolderInfo { + /// Total packed span across every pack stream this folder owns. + fn pack_span(&self) -> usize { + self.pack_sizes.iter().sum() + } + + /// Whether the folder can be decoded here: a linear chain of LZMA/LZMA2 coders over a single + /// pack stream. Non-linear graphs (BCJ2, multi-pack) and unsupported methods list but do not + /// decode. + fn is_decodable(&self) -> bool { + self.graph.is_linear_chain() + && self + .graph + .coders + .iter() + .all(|coder| !matches!(coder.method, CoderMethod::Unsupported)) + } +} + /// A parsed directory-entry-like record: name, kind, permissions, mtime, and (for content files) /// the window into the decompressed folder buffer. #[derive(Debug, Clone)] @@ -319,28 +426,40 @@ impl HeaderParser { } } -enum SevenDecoder { - Lzma2(lzma_rust2::Lzma2Reader>), - Lzma(lzma_rust2::LzmaReader>), +/// One stage of a folder's decode chain, recursively wrapping the stage below it. The innermost +/// stage is the raw pack stream (`Source`); each coder in the folder's decode order wraps the +/// stage below with its own reader, keeping dispatch static (no trait objects). Only LZMA/LZMA2 +/// stages exist today; a folder is built as a chain only when it is a linear LZMA/LZMA2 graph. +enum SevenStage { + /// The base pack stream, bounded to the folder's packed bytes. + Source(Take), + /// An LZMA2 coder reading the stage below. + Lzma2(Box>>), + /// An LZMA (v1) coder reading the stage below. + Lzma(Box>>), } enum SevenInput { Source(R), - Decoder(Box>), + Decoder(Box>), } -impl SevenDecoder { +impl SevenStage { + /// Borrows the raw source at the bottom of the chain. fn source_ref(&self) -> &R { match self { - Self::Lzma2(reader) => reader.inner().get_ref(), - Self::Lzma(reader) => reader.inner().get_ref(), + Self::Source(take) => take.get_ref(), + Self::Lzma2(reader) => reader.inner().source_ref(), + Self::Lzma(reader) => reader.inner().source_ref(), } } - fn into_inner(self) -> R { + /// Unwinds the whole chain, reclaiming the raw source (and freeing every codec workspace). + fn into_source(self) -> R { match self { - Self::Lzma2(reader) => reader.into_inner().into_inner(), - Self::Lzma(reader) => reader.into_inner().into_inner(), + Self::Source(take) => take.into_inner(), + Self::Lzma2(reader) => reader.into_inner().into_source(), + Self::Lzma(reader) => reader.into_inner().into_source(), } } } @@ -353,18 +472,19 @@ impl SevenInput { } } - /// Reclaims the raw source, dropping any live folder decoder (and its codec workspace). + /// Reclaims the raw source, dropping any live folder decode chain (and its codec workspaces). fn into_source(self) -> R { match self { Self::Source(source) => source, - Self::Decoder(decoder) => (*decoder).into_inner(), + Self::Decoder(decoder) => (*decoder).into_source(), } } } -impl Read for SevenDecoder { +impl Read for SevenStage { fn read(&mut self, output: &mut [u8]) -> std::io::Result { match self { + Self::Source(take) => take.read(output), Self::Lzma2(reader) => reader.read(output), Self::Lzma(reader) => reader.read(output), } @@ -740,21 +860,26 @@ impl SevenZSeekReader { return Ok(self.has_decoder()); } self.finalize_active_folder()?; + // Read the folder's decode parameters (all `Copy`), releasing the `self.folders` borrow + // before touching `self.input`. let folder = self .folders .get(folder_index) .ok_or_else(|| seven_error(ErrorKind::Protocol, "folder index out of range"))?; - let coder = folder.coder; + let decodable = folder.is_decodable(); let pack_offset = folder.pack_offset; - let pack_size = folder.pack_size; - let unpack_size = folder.unpack_size; + let pack_size = folder.pack_sizes.first().copied(); let mut source = self.take_source()?; self.decoded_position = 0; self.active_folder = Some(folder_index); - if matches!(coder, FolderCoder::Unsupported) { + if !decodable { self.input = Some(SevenInput::Source(source)); return Ok(false); } + // A decodable folder is a linear chain over exactly one pack stream, so its size is present. + let pack_size = pack_size.ok_or_else(|| { + seven_error(ErrorKind::Protocol, "decodable folder has no pack stream") + })?; let offset = u64::try_from(pack_offset) .map_err(|_| seven_error(ErrorKind::Limit, "pack offset exceeds u64"))?; let size = u64::try_from(pack_size) @@ -765,7 +890,8 @@ impl SevenZSeekReader { return Err(StreamError::io(error)); } let take = source.take(size); - let decoder = build_seven_decoder(take, coder, unpack_size, self.limits)?; + let folder = &self.folders[folder_index]; + let decoder = build_folder_reader(take, folder, self.limits)?; self.input = Some(SevenInput::Decoder(Box::new(decoder))); Ok(true) } @@ -783,7 +909,7 @@ impl SevenZSeekReader { matches!(&self.input, Some(SevenInput::Decoder(_))) } - fn decoder_mut(&mut self) -> Option<&mut SevenDecoder> { + fn decoder_mut(&mut self) -> Option<&mut SevenStage> { match &mut self.input { Some(SevenInput::Decoder(decoder)) => Some(decoder.as_mut()), _ => None, @@ -867,7 +993,7 @@ fn parse_seek_layout( "encoded header must be a single folder", )); }; - if matches!(folder.coder, FolderCoder::Unsupported) { + if !folder.is_decodable() { return Err(seven_error( ErrorKind::Unsupported, "encoded-header coder is unsupported", @@ -919,16 +1045,20 @@ fn decode_seek_header( )); } validate_folder_range(folder, image_length)?; + let pack_size = + folder.pack_sizes.first().copied().ok_or_else(|| { + seven_error(ErrorKind::Unsupported, "encoded-header has no pack stream") + })?; input .seek(SeekFrom::Start(u64::try_from(folder.pack_offset).map_err( |_| seven_error(ErrorKind::Limit, "encoded-header offset exceeds u64"), )?)) .map_err(StreamError::io)?; let take = input.take( - u64::try_from(folder.pack_size) + u64::try_from(pack_size) .map_err(|_| seven_error(ErrorKind::Limit, "encoded-header pack size exceeds u64"))?, ); - let mut decoder = build_seven_decoder(take, folder.coder, folder.unpack_size, limits)?; + let mut decoder = build_folder_reader(take, folder, limits)?; let length = usize::try_from(folder.unpack_size) .map_err(|_| seven_error(ErrorKind::Limit, "decoded header exceeds address space"))?; let mut output = vec![0; length]; @@ -1028,7 +1158,7 @@ fn validate_folder_range( ) -> core::result::Result<(), StreamError> { let start = u64::try_from(folder.pack_offset) .map_err(|_| seven_error(ErrorKind::Limit, "pack offset exceeds u64"))?; - let size = u64::try_from(folder.pack_size) + let size = u64::try_from(folder.pack_span()) .map_err(|_| seven_error(ErrorKind::Limit, "pack size exceeds u64"))?; if start.checked_add(size).is_none_or(|end| end > image_length) { return Err(seven_error( @@ -1039,35 +1169,65 @@ fn validate_folder_range( Ok(()) } -fn build_seven_decoder( - input: Take, - coder: FolderCoder, +/// Folds a folder's linear decode chain over its single pack stream, wrapping each coder in +/// [`resolve_folder`] order (dependencies first) so the outermost stage yields the folder's final +/// output. Only linear LZMA/LZMA2 folders are buildable; anything else reports `Unsupported`, which +/// leaves the folder listable but not extractable. +fn build_folder_reader( + source: Take, + folder: &FolderInfo, + limits: Limits, +) -> core::result::Result, StreamError> { + if !folder.is_decodable() { + return Err(seven_error( + ErrorKind::Unsupported, + "payload coder is unsupported", + )); + } + let mut stage = SevenStage::Source(source); + for &coder_index in &folder.decode_order { + let coder = folder.graph.coders[coder_index]; + // Each stage's uncompressed size is the size of the coder's (single) output stream. + let out_size = folder + .output_sizes + .get(folder.graph.out_base(coder_index)) + .copied() + .ok_or_else(|| seven_error(ErrorKind::Malformed, "coder output size missing"))?; + stage = wrap_coder(stage, coder.method, out_size, limits)?; + } + Ok(stage) +} + +/// Wraps `inner` with a single coder's reader, producing the next chain stage. +fn wrap_coder( + inner: SevenStage, + method: CoderMethod, unpack_size: u64, limits: Limits, -) -> core::result::Result, StreamError> { - match coder { - FolderCoder::Lzma2 { dict_prop } => { +) -> core::result::Result, StreamError> { + match method { + CoderMethod::Lzma2 { dict_prop } => { let dictionary = lzma2_dict_size(dict_prop).map_err(seven_legacy_error)?; validate_dictionary(dictionary, limits)?; - Ok(SevenDecoder::Lzma2(lzma_rust2::Lzma2Reader::new( - input, dictionary, None, - ))) + Ok(SevenStage::Lzma2(Box::new(lzma_rust2::Lzma2Reader::new( + inner, dictionary, None, + )))) }, - FolderCoder::Lzma { props } => { + CoderMethod::Lzma { props } => { let dictionary = u32::from_le_bytes([props[1], props[2], props[3], props[4]]); validate_dictionary(dictionary, limits)?; - Ok(SevenDecoder::Lzma( + Ok(SevenStage::Lzma(Box::new( lzma_rust2::LzmaReader::new_with_props( - input, + inner, unpack_size, props[0], dictionary, None, ) .map_err(|_| seven_error(ErrorKind::Malformed, "LZMA decoder setup failed"))?, - )) + ))) }, - FolderCoder::Unsupported => Err(seven_error( + CoderMethod::Unsupported => Err(seven_error( ErrorKind::Unsupported, "payload coder is unsupported", )), @@ -1116,16 +1276,18 @@ fn seven_error(kind: ErrorKind, context: &'static str) -> StreamError { } /// Parses a `StreamsInfo` (`PackInfo`, `UnpackInfo`, `SubStreamsInfo`) into one [`FolderInfo`] -/// per folder. Any number of folders is accepted, but each folder is restricted to a single -/// LZMA/LZMA2 coder — which means exactly one input (pack) stream per folder, so the pack-stream -/// count must equal the folder count. Pack streams lie back-to-back from the pack base position, -/// giving each folder its own absolute pack offset. +/// per folder. Each folder's full coder graph is parsed (any number of coders, bind pairs, and +/// packed streams) so listing works for every archive; `CodersUnpackSize` carries one size per +/// output stream across all folders, and pack streams lie back-to-back from the pack base, each +/// folder consuming as many as it has packed inputs. Decodability (linear LZMA/LZMA2) is resolved +/// separately at build time. fn parse_streams_info(r: &mut ByteReader<'_>) -> Result> { #![allow(clippy::too_many_lines)] let mut pack_pos = 0u64; let mut pack_sizes: Option> = None; - let mut coders: Vec = Vec::new(); - let mut unpack_sizes: Vec = Vec::new(); + let mut graphs: Vec = Vec::new(); + // One uncompressed size per output stream, flat across every folder in order. + let mut output_sizes_all: Vec = Vec::new(); let mut num_folders = 0usize; // Per folder: whether its UnpackInfo already defined a CRC. Mainstream 7-Zip and `sevenz-rust2` // do NOT put the folder CRC here — they store it only in SubStreamsInfo — so a single-substream @@ -1178,17 +1340,23 @@ fn parse_streams_info(r: &mut ByteReader<'_>) -> Result> { "7z: folder count exceeds header size", )); } - coders = Vec::with_capacity(num_folders); + graphs = Vec::with_capacity(num_folders); for _ in 0..num_folders { - coders.push(read_folder(r)?); + graphs.push(read_folder(r)?); } if r.u8()? != K_CODERS_UNPACK_SIZE { return Err(HeaderError::Malformed("7z: missing coders-unpack-size")); } - // One output stream per single-coder folder, so one unpack size per folder. - unpack_sizes = Vec::with_capacity(num_folders); - for _ in 0..num_folders { - unpack_sizes.push(r.number()?); + // One unpack size per output stream of every coder, in folder-then-coder order. + let total_outputs = graphs.iter().map(Folder::total_out).sum::(); + if total_outputs > r.remaining() { + return Err(HeaderError::Malformed( + "7z: output-stream count exceeds header size", + )); + } + output_sizes_all = Vec::with_capacity(total_outputs); + for _ in 0..total_outputs { + output_sizes_all.push(r.number()?); } folder_has_crc = vec![false; num_folders]; loop { @@ -1206,7 +1374,9 @@ fn parse_streams_info(r: &mut ByteReader<'_>) -> Result> { } }, K_SUBSTREAMS_INFO => { - substream_sizes = Some(parse_substreams_info(r, &unpack_sizes, &folder_has_crc)?); + // SubStreamsInfo needs each folder's final-output (unpack) size. + let folder_unpack = folder_unpack_sizes(&graphs, &output_sizes_all)?; + substream_sizes = Some(parse_substreams_info(r, &folder_unpack, &folder_has_crc)?); }, _ => { return Err(HeaderError::Unsupported( @@ -1219,19 +1389,29 @@ fn parse_streams_info(r: &mut ByteReader<'_>) -> Result> { if num_folders == 0 { return Err(HeaderError::Malformed("7z: missing folder definitions")); } - if coders.len() != num_folders || unpack_sizes.len() != num_folders { + if graphs.len() != num_folders { return Err(HeaderError::Malformed("7z: inconsistent folder table")); } + let total_outputs = graphs.iter().map(Folder::total_out).sum::(); + if output_sizes_all.len() != total_outputs { + return Err(HeaderError::Malformed("7z: coder output-size mismatch")); + } + let folder_unpack = folder_unpack_sizes(&graphs, &output_sizes_all)?; let pack_sizes = pack_sizes.ok_or(HeaderError::Malformed("7z: missing pack sizes"))?; - // A single coder consumes exactly one pack stream, so folders and pack streams pair up 1:1. - if pack_sizes.len() != num_folders { - return Err(HeaderError::Unsupported( - "7z: pack-stream count must equal folder count", + // Every folder draws one pack stream per packed input, so the pack streams must exactly + // cover every folder's packed inputs. + let total_packed = graphs + .iter() + .map(|folder| folder.packed_indices.len()) + .sum::(); + if pack_sizes.len() != total_packed { + return Err(HeaderError::Malformed( + "7z: pack-stream count does not match folder inputs", )); } let substream_sizes = match substream_sizes { Some(sizes) => sizes, - None => unpack_sizes.iter().map(|&size| vec![size]).collect(), + None => folder_unpack.iter().map(|&size| vec![size]).collect(), }; if substream_sizes.len() != num_folders { return Err(HeaderError::Malformed( @@ -1244,69 +1424,279 @@ fn parse_streams_info(r: &mut ByteReader<'_>) -> Result> { .ok_or(HeaderError::Malformed("7z: pack offset overflow"))?; let mut folders = Vec::with_capacity(num_folders); let mut running_pack = base; - for index in 0..num_folders { - let pack_size = usize_of(pack_sizes[index])?; + let mut pack_cursor = 0usize; + let mut out_cursor = 0usize; + for (index, graph) in graphs.into_iter().enumerate() { + let packed_count = graph.packed_indices.len(); + let total_out = graph.total_out(); + // This folder's pack streams and their absolute offsets. let pack_offset = running_pack; - running_pack = running_pack - .checked_add(pack_size) - .ok_or(HeaderError::Malformed("7z: pack offset overflow"))?; + let mut folder_pack_sizes = Vec::with_capacity(packed_count); + for &raw_pack_size in &pack_sizes[pack_cursor..pack_cursor + packed_count] { + let pack_size = usize_of(raw_pack_size)?; + folder_pack_sizes.push(pack_size); + running_pack = running_pack + .checked_add(pack_size) + .ok_or(HeaderError::Malformed("7z: pack offset overflow"))?; + } + pack_cursor += packed_count; + // This folder's per-output sizes. + let output_sizes = output_sizes_all[out_cursor..out_cursor + total_out].to_vec(); + out_cursor += total_out; + // Resolve the decode order (also validates cycles and stream overlaps). + let decode_order = resolve_folder(&graph)?; folders.push(FolderInfo { + graph, pack_offset, - pack_size, - unpack_size: unpack_sizes[index], - coder: coders[index], + pack_sizes: folder_pack_sizes, + unpack_size: folder_unpack[index], + output_sizes, + decode_order, substream_sizes: substream_sizes[index].clone(), }); } Ok(folders) } -/// Parses a single folder definition, returning its coder (LZMA2 or plain LZMA) and properties. -fn read_folder(r: &mut ByteReader<'_>) -> Result { - if r.number()? != 1 { - return Err(HeaderError::Unsupported( - "7z: only a single coder is supported", +/// Each folder's final-output (uncompressed) size, drawn from the flat per-output-stream sizes. +fn folder_unpack_sizes(graphs: &[Folder], output_sizes_all: &[u64]) -> Result> { + let mut sizes = Vec::with_capacity(graphs.len()); + let mut out_cursor = 0usize; + for graph in graphs { + let total_out = graph.total_out(); + let slice = output_sizes_all + .get(out_cursor..out_cursor + total_out) + .ok_or(HeaderError::Malformed("7z: coder output-size mismatch"))?; + let final_out = graph.final_out_index()?; + let size = *slice + .get(final_out) + .ok_or(HeaderError::Malformed("7z: final output size missing"))?; + sizes.push(size); + out_cursor += total_out; + } + Ok(sizes) +} + +/// Parses a single folder's coder graph: its coders, bind pairs, and packed-stream indices. +fn read_folder(r: &mut ByteReader<'_>) -> Result { + let num_coders = usize_of(r.number()?)?; + if num_coders == 0 { + return Err(HeaderError::Malformed("7z: folder has no coders")); + } + if num_coders > r.remaining() { + return Err(HeaderError::Malformed( + "7z: coder count exceeds header size", + )); + } + let mut coders = Vec::with_capacity(num_coders); + for _ in 0..num_coders { + coders.push(read_coder(r)?); + } + let total_in = coders.iter().map(|coder| coder.num_in).sum::(); + let total_out = coders.iter().map(|coder| coder.num_out).sum::(); + if total_out == 0 { + return Err(HeaderError::Malformed("7z: folder has no output streams")); + } + // A folder has (numOutStreams - 1) bind pairs, one per non-final output stream. + let num_bind_pairs = total_out - 1; + if num_bind_pairs > r.remaining() { + return Err(HeaderError::Malformed( + "7z: bind-pair count exceeds header size", )); } + let mut bind_pairs = Vec::with_capacity(num_bind_pairs); + for _ in 0..num_bind_pairs { + let in_index = usize_of(r.number()?)?; + let out_index = usize_of(r.number()?)?; + bind_pairs.push(BindPair { + in_index, + out_index, + }); + } + let num_packed = total_in + .checked_sub(num_bind_pairs) + .filter(|&packed| packed != 0) + .ok_or(HeaderError::Malformed( + "7z: folder has no packed input streams", + ))?; + let packed_indices = if num_packed == 1 { + // The single packed stream is implicit: the one input not consumed by a bind pair. + let index = (0..total_in) + .find(|input| !bind_pairs.iter().any(|pair| pair.in_index == *input)) + .ok_or(HeaderError::Malformed("7z: no free packed input stream"))?; + vec![index] + } else { + let mut indices = Vec::with_capacity(num_packed); + for _ in 0..num_packed { + indices.push(usize_of(r.number()?)?); + } + indices + }; + Ok(Folder { + coders, + bind_pairs, + packed_indices, + }) +} + +/// Parses a single coder: its method id, stream arity, and properties. +fn read_coder(r: &mut ByteReader<'_>) -> Result { let flags = r.u8()?; let id_size = usize::from(flags & 0x0F); let is_complex = flags & 0x10 != 0; let has_attributes = flags & 0x20 != 0; if flags & 0x80 != 0 { - return Err(HeaderError::Unsupported("7z: reserved coder flag set")); - } - let codec = r.bytes(id_size)?; - if is_complex { return Err(HeaderError::Unsupported( - "7z: complex coders are not supported", + "7z: alternative coder methods are unsupported", )); } - if !has_attributes { - return Err(HeaderError::Unsupported("7z: coder without properties")); - } - let prop_size = usize_of(r.number()?)?; - let props = r.bytes(prop_size)?; + // Copy the method id out before further reads reborrow the byte reader. + let codec = r.bytes(id_size)?.to_vec(); + let (num_in, num_out) = if is_complex { + let num_in = usize_of(r.number()?)?; + let num_out = usize_of(r.number()?)?; + // Bound the stream arity against the remaining header so it cannot force a huge allocation. + if num_in > r.remaining() || num_out > r.remaining() { + return Err(HeaderError::Malformed( + "7z: coder stream count exceeds header size", + )); + } + if num_in == 0 || num_out == 0 { + return Err(HeaderError::Malformed( + "7z: coder has no input or output streams", + )); + } + (num_in, num_out) + } else { + (1, 1) + }; + let props = if has_attributes { + let prop_size = usize_of(r.number()?)?; + r.bytes(prop_size)?.to_vec() + } else { + Vec::new() + }; + Ok(Coder { + method: classify_method(&codec, &props)?, + num_in, + num_out, + }) +} + +/// Maps a coder method id and its properties onto a [`CoderMethod`]. LZMA2 and LZMA decode; every +/// other id lists as [`CoderMethod::Unsupported`]. +fn classify_method(codec: &[u8], props: &[u8]) -> Result { if codec == [METHOD_LZMA2] { - if prop_size != 1 { + if props.len() != 1 { return Err(HeaderError::Unsupported( "7z: unexpected LZMA2 property size", )); } - Ok(FolderCoder::Lzma2 { + Ok(CoderMethod::Lzma2 { dict_prop: props[0], }) } else if codec == METHOD_LZMA { - if prop_size != 5 { + if props.len() != 5 { return Err(HeaderError::Unsupported( "7z: unexpected LZMA property size", )); } let mut p = [0u8; 5]; p.copy_from_slice(props); - Ok(FolderCoder::Lzma { props: p }) + Ok(CoderMethod::Lzma { props: p }) } else { - Ok(FolderCoder::Unsupported) + Ok(CoderMethod::Unsupported) + } +} + +/// Resolves a folder's coder graph into a topological decode order (coder indices, +/// dependencies first). Detects cycles (DFS in-progress marks) and stream overlaps — an input fed +/// by more than one source, an input fed by none, or an output bound more than once — all reported +/// as typed `Malformed`. The order is computed for every folder; decodability is a build-time +/// concern handled by [`build_folder_reader`]. +fn resolve_folder(folder: &Folder) -> Result> { + let total_in = folder.total_in(); + let total_out = folder.total_out(); + // Each input stream must have exactly one source; each output at most one consumer. + let mut in_sources = vec![0usize; total_in]; + let mut out_consumers = vec![0usize; total_out]; + for pair in &folder.bind_pairs { + if pair.in_index >= total_in || pair.out_index >= total_out { + return Err(HeaderError::Malformed( + "7z: bind pair references a missing stream", + )); + } + in_sources[pair.in_index] += 1; + out_consumers[pair.out_index] += 1; } + for &packed in &folder.packed_indices { + if packed >= total_in { + return Err(HeaderError::Malformed( + "7z: packed stream references a missing input", + )); + } + in_sources[packed] += 1; + } + if in_sources.iter().any(|&count| count != 1) { + return Err(HeaderError::Malformed( + "7z: overlapping or unfed folder input stream", + )); + } + if out_consumers.iter().any(|&count| count > 1) { + return Err(HeaderError::Malformed( + "7z: overlapping folder output stream", + )); + } + let final_out = folder.final_out_index()?; + let sink = folder + .coder_of_out(final_out) + .ok_or(HeaderError::Malformed("7z: final output has no coder"))?; + + let mut marks = vec![DfsMark::Unvisited; folder.coders.len()]; + let mut order = Vec::with_capacity(folder.coders.len()); + visit_coder(folder, sink, &mut marks, &mut order)?; + Ok(order) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum DfsMark { + Unvisited, + InProgress, + Done, +} + +/// Post-order DFS over the coder graph: a coder is emitted after every coder feeding its inputs, +/// so the resulting order runs dependencies first. Revisiting an in-progress coder is a cycle. +fn visit_coder( + folder: &Folder, + coder: usize, + marks: &mut [DfsMark], + order: &mut Vec, +) -> Result<()> { + match marks[coder] { + DfsMark::Done => return Ok(()), + DfsMark::InProgress => return Err(HeaderError::Malformed("7z: cyclic coder graph")), + DfsMark::Unvisited => {}, + } + marks[coder] = DfsMark::InProgress; + let in_base = folder.in_base(coder); + for local in 0..folder.coders[coder].num_in { + let in_index = in_base + local; + // Bind-paired inputs depend on the producing coder; packed inputs are leaves. + if let Some(pair) = folder + .bind_pairs + .iter() + .find(|pair| pair.in_index == in_index) + { + let producer = folder + .coder_of_out(pair.out_index) + .ok_or(HeaderError::Malformed("7z: bind pair output has no coder"))?; + visit_coder(folder, producer, marks, order)?; + } + } + marks[coder] = DfsMark::Done; + order.push(coder); + Ok(()) } /// Parses a `SubStreamsInfo` block covering every folder, returning per-folder substream sizes. From 68e360c794a26ae90d896891f04e1e7fa1797a2f Mon Sep 17 00:00:00 2001 From: Yasunobu <42543015+P4suta@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:38:19 +0900 Subject: [PATCH 3/8] feat(7z): delta and BCJ decode filters in the coder graph [RM-303] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Landing step 3 of the 7z coder-graph work: decode-only delta and BCJ byte filters, wired into the folder decode chain so a linear graph that chains them with LZMA2/LZMA over one pack stream now extracts (previously listed as Unsupported). Exactly one folder decoder stays live; bounded memory and static dispatch are unchanged. Filters (new filter/delta.rs, filter/bcj.rs, under the sevenz feature): - DeltaDecoder: per-byte reconstruction, distance 1..=256 over a 256-byte history ring. Fully streaming (no held state), so any chunking matches. - BcjDecoder: BranchKind covering x86 (stateful E8/E9 with running mask and IP), the 4-byte-stride RISC families (ARM/ARM64/PPC/SPARC), 2-byte ARM Thumb, 16-byte IA-64, and variable-width RISC-V. Instructions straddling a chunk boundary are held back and re-scanned; at end of input the remaining tail is emitted untransformed, exactly as the encoder left it. Decode is a bit-for-bit inverse of the reference encoders. - Both implement the sans-I/O Codec and are driven by the shared CodecReader, so dispatch stays static. codec_read is now compiled under sevenz too, and its struct bounds moved onto the impls so SevenStage can nest it. Parser/decode (sevenz.rs): - classify_method recognizes the delta id (0x03, distance = prop + 1) and the BCJ ids (x86/PPC/IA64/ARM/ARMT/SPARC long-form, ARM64 0x0A, RISC-V 0x0B), mapping them to CoderMethod::Filter(SevenFilter::{Delta,Bcj}). Unknown property shapes still list as Unsupported. - SevenStage gains Delta/Bcj stages wrapping CodecReader over the stage below; wrap_coder builds them. A filter coder is single-in/single-out, so a folder chaining it with LZMA2/LZMA stays a linear decodable chain. Tests: - Differential vs sevenz-rust2: delta+LZMA2 (distances 1/4/256) and BCJ+LZMA2 (x86/ARM/PPC/SPARC) archives are produced by sevenz-rust2 and decoded by arca to byte equality, cross-checked against sevenz-rust2's own reader. - Filter unit tests decode every branch kind and every delta distance against lzma_rust2's independent encoder/decoder, across tiny (1/3-byte) and bulk output buffers and small/large input chunks, plus near-window-boundary lengths — proving chunk-size independence. Verified: build -p libarchive_oxide with sevenz, "sevenz aes", and --no-default-features --features sevenz, plus default; sevenz_differential and filter unit tests green on each; clippy clean; xtask no-dyn and codec-policy pass (no new deps; portable stays C/FFI-free). #![forbid( unsafe_code)] holds. Cross/qemu/macOS matrices deferred to CI. Co-Authored-By: Claude Opus 4.8 (1M context) --- libarchive_oxide/src/codec_read.rs | 8 +- libarchive_oxide/src/filter/bcj.rs | 786 ++++++++++++++++++ libarchive_oxide/src/filter/delta.rs | 168 ++++ libarchive_oxide/src/filter/mod.rs | 4 + libarchive_oxide/src/lib.rs | 2 +- libarchive_oxide/src/sevenz.rs | 112 ++- libarchive_oxide/tests/sevenz_differential.rs | 94 ++- 7 files changed, 1160 insertions(+), 14 deletions(-) create mode 100644 libarchive_oxide/src/filter/bcj.rs create mode 100644 libarchive_oxide/src/filter/delta.rs diff --git a/libarchive_oxide/src/codec_read.rs b/libarchive_oxide/src/codec_read.rs index 3fc5045..0c9ebed 100644 --- a/libarchive_oxide/src/codec_read.rs +++ b/libarchive_oxide/src/codec_read.rs @@ -22,7 +22,7 @@ pub(crate) const BUFFER: usize = 64 * 1024; /// Generic over the inner reader `Inner` and the codec `C`, so a single /// implementation serves every codec that implements [`Codec`]. `name` labels /// the codec in error messages. -pub(crate) struct CodecReader { +pub(crate) struct CodecReader { input: Inner, decoder: C, name: &'static str, @@ -55,6 +55,12 @@ impl CodecReader { self.input } + /// Borrows the inner reader (used to reach the raw source below a chain). + #[cfg(feature = "sevenz")] + pub(crate) fn get_ref(&self) -> &Inner { + &self.input + } + fn fill(&mut self) -> io::Result<()> { if self.start != 0 { self.buffer.copy_within(self.start..self.end, 0); diff --git a/libarchive_oxide/src/filter/bcj.rs b/libarchive_oxide/src/filter/bcj.rs new file mode 100644 index 0000000..1984f4d --- /dev/null +++ b/libarchive_oxide/src/filter/bcj.rs @@ -0,0 +1,786 @@ +// SPDX-FileCopyrightText: 2026 libarchive_oxide contributors +// +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Branch/Call/Jump (BCJ) decode filters as a sans-I/O [`Codec`]. +//! +//! BCJ filters convert position-relative branch targets back to their stored, +//! position-independent form so that repeated call/jump instructions compress +//! better. This module is decode-only: it inverts the transform 7z/XZ encoders +//! apply. The transform at each instruction is a pure function of the bytes at +//! that position, the running instruction pointer, and (for x86) a small running +//! mask, so the reconstructed bytes are independent of how the input is chunked. +//! +//! Each family scans on a fixed stride (x86 byte-by-byte with stateful E8/E9 +//! detection; ARM/ARM64/PPC/SPARC on 4-byte, ARM-Thumb on 2-byte, IA-64 on +//! 16-byte, RISC-V on 2/4/8-byte instructions). Because a branch instruction may +//! straddle a chunk boundary, [`BcjDecoder`] retains the trailing bytes that do +//! not yet form a complete instruction window and re-scans them once more input +//! arrives; at end of input any remaining tail is emitted untransformed, exactly +//! as the encoder left it. + +// The branch transforms are ported bit-for-bit from the reference filters and rely on wrapping, +// sign-reinterpreting integer casts throughout; individual casts are not independently meaningful. +#![allow( + clippy::cast_possible_truncation, + clippy::cast_possible_wrap, + clippy::cast_sign_loss +)] + +use libarchive_oxide_core::{ArchiveError, Codec, CodecStatus, CodecStep, EndOfInput}; + +/// Which instruction family a BCJ filter targets. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BranchKind { + /// x86 `E8`/`E9` call/jump (stateful, byte-granular). + X86, + /// 32-bit ARM `BL`. + Arm, + /// ARM Thumb `BL`/`BLX`. + ArmThumb, + /// 64-bit ARM `BL`/`ADRP`. + Arm64, + /// PowerPC `bl`. + Ppc, + /// SPARC `call`. + Sparc, + /// Itanium (IA-64) bundles. + Ia64, + /// RISC-V `JAL`/`AUIPC`. + RiscV, +} + +/// Running BCJ transform state (instruction pointer plus x86 mask). +struct BcjFilter { + kind: BranchKind, + /// Running position of the first byte of the buffer handed to [`Self::decode`]. + pos: usize, + /// x86-only running branch mask carried across instructions. + prev_mask: u32, +} + +const MASK_TO_ALLOWED_STATUS: [bool; 8] = [true, true, true, false, true, false, false, false]; +const MASK_TO_BIT_NUMBER: [u32; 8] = [0, 1, 2, 2, 3, 3, 3, 3]; + +#[inline] +const fn test_86_ms_byte(b: u8) -> bool { + b == 0x00 || b == 0xFF +} + +impl BcjFilter { + fn new(kind: BranchKind, start_pos: usize) -> Self { + // Per-family starting offsets mirror the reference encoders so decode is + // their exact inverse. + let pos = match kind { + BranchKind::X86 => start_pos.wrapping_add(5), + BranchKind::Arm => start_pos.wrapping_add(8), + BranchKind::ArmThumb => start_pos.wrapping_add(4), + BranchKind::Arm64 + | BranchKind::Ppc + | BranchKind::Sparc + | BranchKind::Ia64 + | BranchKind::RiscV => start_pos, + }; + Self { + kind, + pos, + prev_mask: 0, + } + } + + /// Transforms a leading run of complete instructions in place and returns the + /// number of bytes committed. Trailing bytes that cannot yet hold a full + /// instruction window are left untouched for the next call. + fn decode(&mut self, buf: &mut [u8]) -> usize { + match self.kind { + BranchKind::X86 => self.x86(buf), + BranchKind::Arm => self.arm(buf), + BranchKind::ArmThumb => self.arm_thumb(buf), + BranchKind::Arm64 => self.arm64(buf), + BranchKind::Ppc => self.ppc(buf), + BranchKind::Sparc => self.sparc(buf), + BranchKind::Ia64 => self.ia64(buf), + BranchKind::RiscV => self.riscv(buf), + } + } + + fn x86(&mut self, buf: &mut [u8]) -> usize { + let len = buf.len(); + if len < 5 { + return 0; + } + let end = len - 5; + let mut prev_pos: isize = -1; + let mut prev_mask = self.prev_mask; + let mut i = 0usize; + while i <= end { + let b = buf[i]; + if b != 0xE9 && b != 0xE8 { + i += 1; + continue; + } + prev_pos = i as isize - prev_pos; + if (prev_pos & !3) != 0 { + prev_mask = 0; + } else { + prev_mask = (prev_mask << ((prev_pos - 1) as u32)) & 7; + if prev_mask != 0 + && (!MASK_TO_ALLOWED_STATUS[prev_mask as usize] + || test_86_ms_byte( + buf[i + 4 - MASK_TO_BIT_NUMBER[prev_mask as usize] as usize], + )) + { + prev_pos = i as isize; + prev_mask = (prev_mask << 1) | 1; + i += 1; + continue; + } + } + + prev_pos = i as isize; + if test_86_ms_byte(buf[i + 4]) { + let mut src = i32::from(buf[i + 1]) + | (i32::from(buf[i + 2]) << 8) + | (i32::from(buf[i + 3]) << 16) + | (i32::from(buf[i + 4]) << 24); + let mut dest: i32; + loop { + dest = src.wrapping_sub((self.pos.wrapping_add(i)) as i32); + if prev_mask == 0 { + break; + } + let index = MASK_TO_BIT_NUMBER[prev_mask as usize] * 8; + if !test_86_ms_byte(((dest >> (24 - index)) & 0xFF) as u8) { + break; + } + src = dest ^ ((1i32 << (32 - index)) - 1); + } + + buf[i + 1] = dest as u8; + buf[i + 2] = (dest >> 8) as u8; + buf[i + 3] = (dest >> 16) as u8; + buf[i + 4] = (!(((dest >> 24) & 1) - 1)) as u8; + i += 4; + } else { + prev_mask = (prev_mask << 1) | 1; + } + i += 1; + } + + prev_pos = i as isize - prev_pos; + prev_mask = if (prev_pos & !3) != 0 { + 0 + } else { + prev_mask << ((prev_pos - 1) as u32) + }; + + self.prev_mask = prev_mask; + self.pos = self.pos.wrapping_add(i); + i + } + + fn arm(&mut self, buf: &mut [u8]) -> usize { + let len = buf.len(); + if len < 4 { + return 0; + } + let end = len - 4; + let mut i = 0usize; + while i <= end { + if buf[i + 3] == 0xEB { + let b2 = i32::from(buf[i + 2]); + let b1 = i32::from(buf[i + 1]); + let b0 = i32::from(buf[i]); + + let src = ((b2 << 16) | (b1 << 8) | b0) << 2; + let p = self.pos.wrapping_add(i) as i32; + let dest = src.wrapping_sub(p) >> 2; + buf[i + 2] = ((dest >> 16) & 0xFF) as u8; + buf[i + 1] = ((dest >> 8) & 0xFF) as u8; + buf[i] = (dest & 0xFF) as u8; + } + i += 4; + } + self.pos = self.pos.wrapping_add(i); + i + } + + fn arm_thumb(&mut self, buf: &mut [u8]) -> usize { + let len = buf.len(); + if len < 4 { + return 0; + } + let end = len - 4; + let mut i = 0usize; + while i <= end { + let b1 = i32::from(buf[i + 1]); + let b3 = i32::from(buf[i + 3]); + if (b3 & 0xF8) == 0xF8 && (b1 & 0xF8) == 0xF0 { + let b2 = i32::from(buf[i + 2]); + let b0 = i32::from(buf[i]); + + let src = + ((b1 & 0x07) << 19) | ((b0 & 0xFF) << 11) | ((b3 & 0x07) << 8) | (b2 & 0xFF); + let src = src << 1; + let dest = src.wrapping_sub(self.pos.wrapping_add(i) as i32) >> 1; + buf[i + 1] = (0xF0 | ((dest >> 19) & 0x07)) as u8; + buf[i] = (dest >> 11) as u8; + buf[i + 3] = (0xF8 | ((dest >> 8) & 0x07)) as u8; + buf[i + 2] = (dest & 0xFF) as u8; + i += 2; + } + i += 2; + } + self.pos = self.pos.wrapping_add(i); + i + } + + fn arm64(&mut self, buf: &mut [u8]) -> usize { + let len = buf.len(); + if len < 4 { + return 0; + } + let end = len - 4; + let mut i = 0usize; + while i <= end { + let b3 = i32::from(buf[i + 3]); + let b2 = i32::from(buf[i + 2]); + let b1 = i32::from(buf[i + 1]); + let b0 = i32::from(buf[i]); + + let src = (b3 << 24) + (b2 << 16) + (b1 << 8) + b0; + let p = self.pos.wrapping_add(i) as i32; + + // BL + if ((src >> 26) & 0x3F) == 0x25 { + let dest_adr = src.wrapping_sub(p >> 2); + let dest = (dest_adr & 0x03FF_FFFF) | (0x94 << 24); + + buf[i + 3] = ((dest >> 24) & 0xFF) as u8; + buf[i + 2] = ((dest >> 16) & 0xFF) as u8; + buf[i + 1] = ((dest >> 8) & 0xFF) as u8; + buf[i] = (dest & 0xFF) as u8; + } + + // ADRP + if ((src >> 24) & 0x9F) == 0x90 { + let addr = ((src >> 29) & 3) | ((src >> 3) & 0x001F_FFFC); + + if 0 == (addr.wrapping_add(0x0002_0000) & 0x001C_0000) { + let dest = (0x90 << 24) | (src & 0x1F); + let addr = addr.wrapping_sub(p >> 12); + let dest = dest | ((addr & 3) << 29); + let dest = dest | ((addr & 0x0003_FFFC) << 3); + let dest = dest | (0i32.wrapping_sub(addr & 0x0002_0000) & 0x00E0_0000); + + buf[i + 3] = ((dest >> 24) & 0xFF) as u8; + buf[i + 2] = ((dest >> 16) & 0xFF) as u8; + buf[i + 1] = ((dest >> 8) & 0xFF) as u8; + buf[i] = (dest & 0xFF) as u8; + } + } + + i += 4; + } + self.pos = self.pos.wrapping_add(i); + i + } + + fn ppc(&mut self, buf: &mut [u8]) -> usize { + let len = buf.len(); + if len < 4 { + return 0; + } + let end = len - 4; + let mut i = 0usize; + while i <= end { + let b3 = i32::from(buf[i + 3]); + let b0 = i32::from(buf[i]); + + if (b0 & 0xFC) == 0x48 && (b3 & 0x03) == 0x01 { + let b2 = i32::from(buf[i + 2]); + let b1 = i32::from(buf[i + 1]); + + let src = + ((b0 & 0x03) << 24) | ((b1 & 0xFF) << 16) | ((b2 & 0xFF) << 8) | (b3 & 0xFC); + let p = self.pos.wrapping_add(i) as i32; + let dest = src.wrapping_sub(p); + + buf[i] = (0x48 | ((dest >> 24) & 0x03)) as u8; + buf[i + 1] = (dest >> 16) as u8; + buf[i + 2] = (dest >> 8) as u8; + buf[i + 3] = ((b3 & 0x03) | dest) as u8; + } + i += 4; + } + self.pos = self.pos.wrapping_add(i); + i + } + + fn sparc(&mut self, buf: &mut [u8]) -> usize { + let len = buf.len(); + if len < 4 { + return 0; + } + let end = len - 4; + let mut i = 0usize; + while i <= end { + let b0 = i32::from(buf[i]); + let b1 = i32::from(buf[i + 1]); + + if (b0 == 0x40 && (b1 & 0xC0) == 0x00) || (b0 == 0x7F && (b1 & 0xC0) == 0xC0) { + let b2 = i32::from(buf[i + 2]); + let b3 = i32::from(buf[i + 3]); + + let src = + ((b0 & 0xFF) << 24) | ((b1 & 0xFF) << 16) | ((b2 & 0xFF) << 8) | (b3 & 0xFF); + let src = src << 2; + let p = self.pos.wrapping_add(i) as i32; + let dest = src.wrapping_sub(p) >> 2; + let dest = (((0 - ((dest >> 22) & 1)) << 22) & 0x3FFF_FFFF) + | (dest & 0x3F_FFFF) + | 0x4000_0000; + + buf[i] = (dest >> 24) as u8; + buf[i + 1] = (dest >> 16) as u8; + buf[i + 2] = (dest >> 8) as u8; + buf[i + 3] = dest as u8; + } + i += 4; + } + self.pos = self.pos.wrapping_add(i); + i + } + + fn ia64(&mut self, buf: &mut [u8]) -> usize { + const BRANCH_TABLE: [u32; 32] = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 4, 6, 6, 0, 0, 7, 7, 4, 4, 0, 0, 4, + 4, 0, 0, + ]; + + let len = buf.len(); + if len < 16 { + return 0; + } + let end = len - 16; + let mut i = 0usize; + + while i <= end { + let instr_template = (buf[i] & 0x1F) as usize; + let mask = BRANCH_TABLE[instr_template]; + + for slot in 0..3 { + let bit_pos = 5 + slot * 41; + if ((mask >> slot) & 1) == 0 { + continue; + } + + let byte_pos = bit_pos >> 3; + let bit_res = bit_pos & 7; + + let mut instr: u64 = 0; + for j in 0..6 { + if i + byte_pos + j < buf.len() { + instr |= u64::from(buf[i + byte_pos + j]) << (8 * j); + } + } + + let instr_norm = instr >> bit_res; + + if ((instr_norm >> 37) & 0x0F) != 0x05 || ((instr_norm >> 9) & 0x07) != 0x00 { + continue; + } + + let mut src = ((instr_norm >> 13) & 0x0F_FFFF) as i32; + src |= (((instr_norm >> 36) & 1) as i32) << 20; + src <<= 4; + + let dest = src.wrapping_sub((self.pos as i32).wrapping_add(i as i32)); + let dest = (dest as u32) >> 4; + + let mut instr_norm = instr_norm; + instr_norm &= !(0x8F_FFFF_u64 << 13); + instr_norm |= u64::from(dest & 0x0F_FFFF) << 13; + instr_norm |= u64::from(dest & 0x10_0000) << (36 - 20); + + let mut instr = instr & ((1_u64 << bit_res) - 1); + instr |= instr_norm << bit_res; + + for j in 0..6 { + if i + byte_pos + j < buf.len() { + buf[i + byte_pos + j] = (instr >> (8 * j)) as u8; + } + } + } + + i += 16; + } + + self.pos = self.pos.wrapping_add(i); + i + } + + fn riscv(&mut self, buf: &mut [u8]) -> usize { + let len = buf.len(); + if len < 8 { + return 0; + } + let end = len - 8; + let mut i = 0usize; + + while i <= end { + let inst = u32::from(buf[i]); + + if inst == 0xEF { + // JAL + let b1 = u32::from(buf[i + 1]); + if (b1 & 0x0D) != 0 { + i += 2; + continue; + } + + let b2 = u32::from(buf[i + 2]); + let b3 = u32::from(buf[i + 3]); + let pc = (self.pos + i) as i32; + + let addr = ((b1 & 0xF0) << 13) | (b2 << 9) | (b3 << 1); + let addr = (addr as i32).wrapping_sub(pc); + + buf[i + 1] = ((b1 & 0x0F) | ((addr as u32 >> 8) & 0xF0)) as u8; + buf[i + 2] = + (((addr >> 16) & 0x0F) | ((addr >> 7) & 0x10) | ((addr << 4) & 0xE0)) as u8; + buf[i + 3] = (((addr >> 4) & 0x7F) | ((addr >> 13) & 0x80)) as u8; + + i += 4; + } else if (inst & 0x7F) == 0x17 { + // AUIPC + let mut inst_full = inst + | (u32::from(buf[i + 1]) << 8) + | (u32::from(buf[i + 2]) << 16) + | (u32::from(buf[i + 3]) << 24); + + if (inst_full & 0xE80) != 0 { + let inst2 = + u32::from_le_bytes([buf[i + 4], buf[i + 5], buf[i + 6], buf[i + 7]]); + + if (((inst_full << 8) ^ inst2) & 0xF_8003) != 3 { + i += 6; + continue; + } + + let addr = + ((inst_full & 0xFFFF_F000) as i32).wrapping_add((inst2 >> 20) as i32); + + inst_full = 0x17 | (2 << 7) | (inst2 << 12); + let inst2_new = addr; + + buf[i] = inst_full as u8; + buf[i + 1] = (inst_full >> 8) as u8; + buf[i + 2] = (inst_full >> 16) as u8; + buf[i + 3] = (inst_full >> 24) as u8; + + buf[i + 4] = inst2_new as u8; + buf[i + 5] = (inst2_new >> 8) as u8; + buf[i + 6] = (inst2_new >> 16) as u8; + buf[i + 7] = (inst2_new >> 24) as u8; + } else { + let fake_rs1 = inst_full >> 27; + + if ((inst_full.wrapping_sub(0x3100)) & 0x3F80) >= (fake_rs1 & 0x1D) { + i += 4; + continue; + } + + let addr = i32::from_be_bytes([buf[i + 4], buf[i + 5], buf[i + 6], buf[i + 7]]); + let addr = addr.wrapping_sub((self.pos + i) as i32); + + let inst2_rs1 = inst_full >> 27; + let inst2 = (inst_full >> 12) | ((addr as u32) << 20); + + inst_full = + 0x17 | (inst2_rs1 << 7) | ((addr.wrapping_add(0x800) as u32) & 0xFFFF_F000); + + buf[i] = inst_full as u8; + buf[i + 1] = (inst_full >> 8) as u8; + buf[i + 2] = (inst_full >> 16) as u8; + buf[i + 3] = (inst_full >> 24) as u8; + + buf[i + 4] = inst2 as u8; + buf[i + 5] = (inst2 >> 8) as u8; + buf[i + 6] = (inst2 >> 16) as u8; + buf[i + 7] = (inst2 >> 24) as u8; + } + + i += 8; + } else { + i += 2; + } + } + + self.pos = self.pos.wrapping_add(i); + i + } +} + +/// Incremental BCJ decoder driving a [`BcjFilter`] with a small carry buffer so +/// that instruction windows straddling input chunks are handled correctly. +pub(crate) struct BcjDecoder { + filter: BcjFilter, + /// Encoded bytes accepted but not yet committed as a complete instruction run. + buf: Vec, + /// Decoded bytes ready to emit. + out: Vec, + /// Emit cursor into `out`. + out_pos: usize, + finished: bool, +} + +impl BcjDecoder { + /// Builds a decoder for `kind`, with the running position seeded from `start`. + pub(crate) fn new(kind: BranchKind, start: usize) -> Self { + Self { + filter: BcjFilter::new(kind, start), + buf: Vec::new(), + out: Vec::new(), + out_pos: 0, + finished: false, + } + } +} + +impl core::fmt::Debug for BcjDecoder { + fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + formatter + .debug_struct("BcjDecoder") + .field("kind", &self.filter.kind) + .field("buffered", &self.buf.len()) + .field("pending", &(self.out.len() - self.out_pos)) + .finish_non_exhaustive() + } +} + +impl Codec for BcjDecoder { + fn process( + &mut self, + input: &[u8], + output: &mut [u8], + end: EndOfInput, + ) -> Result { + // Phase A: drain decoded bytes already staged from an earlier call. + if self.out_pos < self.out.len() { + let available = self.out.len() - self.out_pos; + let count = available.min(output.len()); + output[..count].copy_from_slice(&self.out[self.out_pos..self.out_pos + count]); + self.out_pos += count; + if self.out_pos == self.out.len() { + self.out.clear(); + self.out_pos = 0; + } + return Ok(CodecStep { + consumed: 0, + produced: count, + status: CodecStatus::NeedOutput, + }); + } + if self.finished { + return Ok(CodecStep { + consumed: 0, + produced: 0, + status: CodecStatus::Done, + }); + } + + // Phase B: ingest this call's input and either filter it or, at true end + // of input, flush the untransformed tail. + let consumed = input.len(); + if consumed > 0 { + self.buf.extend_from_slice(input); + } + let at_end = matches!(end, EndOfInput::End) && consumed == 0; + if at_end { + // The trailing bytes that never formed a complete instruction window + // are emitted exactly as stored. + core::mem::swap(&mut self.out, &mut self.buf); + self.finished = true; + } else { + let filtered = self.filter.decode(&mut self.buf); + if filtered > 0 { + self.out.extend_from_slice(&self.buf[..filtered]); + self.buf.drain(..filtered); + } + } + + // Phase C: emit whatever the filter produced. + if self.out.is_empty() { + let status = if self.finished { + CodecStatus::Done + } else { + // Input consumed but no complete instruction yet; ask for more. + CodecStatus::NeedInput + }; + return Ok(CodecStep { + consumed, + produced: 0, + status, + }); + } + let count = self.out.len().min(output.len()); + output[..count].copy_from_slice(&self.out[..count]); + self.out_pos = count; + if self.out_pos == self.out.len() { + self.out.clear(); + self.out_pos = 0; + } + Ok(CodecStep { + consumed, + produced: count, + status: CodecStatus::NeedInput, + }) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use std::io::{Cursor, Read, Write}; + + use lzma_rust2::filter::bcj::{BcjReader, BcjWriter}; + + use super::*; + use crate::codec_read::CodecReader; + + /// Deterministic pseudo-random bytes so branch opcodes actually occur and get transformed. + fn pseudo_random(len: usize) -> Vec { + let mut state: u64 = 0x1234_5678_9abc_def1; + (0..len) + .map(|_| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + (state >> 24) as u8 + }) + .collect() + } + + /// A reader that yields at most `chunk` bytes per `read`, to stress input-boundary handling. + struct ChunkReader { + data: Vec, + pos: usize, + chunk: usize, + } + + impl Read for ChunkReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let remaining = self.data.len() - self.pos; + let n = remaining.min(self.chunk).min(buf.len()); + buf[..n].copy_from_slice(&self.data[self.pos..self.pos + n]); + self.pos += n; + Ok(n) + } + } + + fn encode(kind: BranchKind, data: &[u8]) -> Vec { + let cursor = Cursor::new(Vec::new()); + let mut writer = match kind { + BranchKind::X86 => BcjWriter::new_x86(cursor, 0), + BranchKind::Arm => BcjWriter::new_arm(cursor, 0), + BranchKind::ArmThumb => BcjWriter::new_arm_thumb(cursor, 0), + BranchKind::Arm64 => BcjWriter::new_arm64(cursor, 0), + BranchKind::Ppc => BcjWriter::new_ppc(cursor, 0), + BranchKind::Sparc => BcjWriter::new_sparc(cursor, 0), + BranchKind::Ia64 => BcjWriter::new_ia64(cursor, 0), + BranchKind::RiscV => BcjWriter::new_riscv(cursor, 0), + }; + writer.write_all(data).unwrap(); + writer.finish().unwrap().into_inner() + } + + fn reference_decode(kind: BranchKind, encoded: &[u8]) -> Vec { + let cursor = Cursor::new(encoded.to_vec()); + let mut reader = match kind { + BranchKind::X86 => BcjReader::new_x86(cursor, 0), + BranchKind::Arm => BcjReader::new_arm(cursor, 0), + BranchKind::ArmThumb => BcjReader::new_arm_thumb(cursor, 0), + BranchKind::Arm64 => BcjReader::new_arm64(cursor, 0), + BranchKind::Ppc => BcjReader::new_ppc(cursor, 0), + BranchKind::Sparc => BcjReader::new_sparc(cursor, 0), + BranchKind::Ia64 => BcjReader::new_ia64(cursor, 0), + BranchKind::RiscV => BcjReader::new_riscv(cursor, 0), + }; + let mut out = Vec::new(); + reader.read_to_end(&mut out).unwrap(); + out + } + + fn arca_decode(kind: BranchKind, encoded: &[u8], out_chunk: usize, in_chunk: usize) -> Vec { + let inner = ChunkReader { + data: encoded.to_vec(), + pos: 0, + chunk: in_chunk, + }; + let mut reader = CodecReader::new(inner, BcjDecoder::new(kind, 0), "bcj"); + let mut out = Vec::new(); + let mut buf = vec![0u8; out_chunk]; + loop { + let n = reader.read(&mut buf).unwrap(); + if n == 0 { + break; + } + out.extend_from_slice(&buf[..n]); + } + out + } + + #[test] + fn every_branch_kind_round_trips_against_lzma_rust2() { + let data = pseudo_random(150_003); + for kind in [ + BranchKind::X86, + BranchKind::Arm, + BranchKind::ArmThumb, + BranchKind::Arm64, + BranchKind::Ppc, + BranchKind::Sparc, + BranchKind::Ia64, + BranchKind::RiscV, + ] { + let encoded = encode(kind, &data); + // The reference decoder must reconstruct the input (encoder actually transformed it). + assert_eq!( + reference_decode(kind, &encoded), + data, + "reference round trip failed for {kind:?}" + ); + // A non-trivial transform occurred for at least the byte-granular x86 filter. + if kind == BranchKind::X86 { + assert_ne!(encoded, data, "x86 encoder made no change"); + } + // arca decodes byte-identically regardless of input/output chunking. + for (out_chunk, in_chunk) in [(1, 1), (3, 7), (64, 5), (4096, 64), (200_000, 200_000)] { + assert_eq!( + arca_decode(kind, &encoded, out_chunk, in_chunk), + data, + "arca decode mismatch for {kind:?} at out={out_chunk} in={in_chunk}" + ); + } + } + } + + #[test] + fn tiny_output_matches_bulk_output() { + // Empty and near-window-boundary lengths for the x86 filter's held-back tail. + for len in [0usize, 1, 4, 5, 6, 8, 4095, 4096, 4097] { + let data = pseudo_random(len); + let encoded = encode(BranchKind::X86, &data); + let bulk = arca_decode( + BranchKind::X86, + &encoded, + usize::max(len, 1), + usize::max(len, 1), + ); + let single = arca_decode(BranchKind::X86, &encoded, 1, 1); + assert_eq!(bulk, data, "bulk decode mismatch at len {len}"); + assert_eq!(single, data, "single-byte decode mismatch at len {len}"); + } + } +} diff --git a/libarchive_oxide/src/filter/delta.rs b/libarchive_oxide/src/filter/delta.rs new file mode 100644 index 0000000..9362e10 --- /dev/null +++ b/libarchive_oxide/src/filter/delta.rs @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: 2026 libarchive_oxide contributors +// +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Delta decode filter as a sans-I/O [`Codec`]. +//! +//! The 7z/XZ delta filter reconstructs each output byte by adding the byte +//! `distance` positions back in the already-decoded history. It is fully +//! per-byte and carries no partial-instruction state, so any input chunking +//! yields the same output. `distance` is `1..=256`; the history ring is a fixed +//! 256-byte buffer indexed modulo 256. + +use libarchive_oxide_core::{ArchiveError, Codec, CodecStatus, CodecStep, EndOfInput}; + +/// The delta filter's fixed maximum distance (and history ring size). +const MAX_DISTANCE: usize = 256; +/// Mask folding a ring index into `0..MAX_DISTANCE`. +const DIS_MASK: usize = MAX_DISTANCE - 1; + +/// Incremental delta decoder. Decode-only: it inverts the encoder by adding the +/// historical byte back to each delta-coded input byte. +pub(crate) struct DeltaDecoder { + distance: usize, + history: [u8; MAX_DISTANCE], + pos: u8, +} + +impl DeltaDecoder { + /// Builds a decoder for `distance` (clamped to the valid `1..=256` range). + pub(crate) fn new(distance: usize) -> Self { + Self { + distance: distance.clamp(1, MAX_DISTANCE), + history: [0; MAX_DISTANCE], + pos: 0, + } + } +} + +impl core::fmt::Debug for DeltaDecoder { + fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + formatter + .debug_struct("DeltaDecoder") + .field("distance", &self.distance) + .finish_non_exhaustive() + } +} + +impl Codec for DeltaDecoder { + fn process( + &mut self, + input: &[u8], + output: &mut [u8], + end: EndOfInput, + ) -> Result { + let count = input.len().min(output.len()); + for (dst, &coded) in output[..count].iter_mut().zip(&input[..count]) { + let pos = self.pos as usize; + let history = self.history[self.distance.wrapping_add(pos) & DIS_MASK]; + let value = coded.wrapping_add(history); + *dst = value; + self.history[pos & DIS_MASK] = value; + self.pos = self.pos.wrapping_sub(1); + } + if count > 0 { + // `consumed == produced`; the status is only consulted on zero progress. + return Ok(CodecStep { + consumed: count, + produced: count, + status: CodecStatus::NeedInput, + }); + } + // Output is always non-empty here, so zero progress means the input is empty. + let status = match end { + EndOfInput::End => CodecStatus::Done, + EndOfInput::More => CodecStatus::NeedInput, + }; + Ok(CodecStep { + consumed: 0, + produced: 0, + status, + }) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::cast_possible_truncation)] +mod tests { + use std::io::{Cursor, Read, Write}; + + use lzma_rust2::filter::delta::{DeltaReader, DeltaWriter}; + + use super::*; + use crate::codec_read::CodecReader; + + fn pseudo_random(len: usize) -> Vec { + let mut state: u64 = 0x0f1e_2d3c_4b5a_6978; + (0..len) + .map(|_| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + (state >> 24) as u8 + }) + .collect() + } + + struct ChunkReader { + data: Vec, + pos: usize, + chunk: usize, + } + + impl Read for ChunkReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let n = (self.data.len() - self.pos).min(self.chunk).min(buf.len()); + buf[..n].copy_from_slice(&self.data[self.pos..self.pos + n]); + self.pos += n; + Ok(n) + } + } + + fn encode(distance: usize, data: &[u8]) -> Vec { + let mut writer = DeltaWriter::new(Cursor::new(Vec::new()), distance); + writer.write_all(data).unwrap(); + writer.into_inner().into_inner() + } + + fn arca_decode(distance: usize, encoded: &[u8], out_chunk: usize, in_chunk: usize) -> Vec { + let inner = ChunkReader { + data: encoded.to_vec(), + pos: 0, + chunk: in_chunk, + }; + let mut reader = CodecReader::new(inner, DeltaDecoder::new(distance), "delta"); + let mut out = Vec::new(); + let mut buf = vec![0u8; out_chunk]; + loop { + let n = reader.read(&mut buf).unwrap(); + if n == 0 { + break; + } + out.extend_from_slice(&buf[..n]); + } + out + } + + #[test] + fn delta_round_trips_against_lzma_rust2_at_every_distance() { + let data = pseudo_random(70_007); + for distance in [1usize, 2, 3, 4, 16, 255, 256] { + let encoded = encode(distance, &data); + // Reference reader reconstructs the input. + let mut reference = Vec::new(); + DeltaReader::new(Cursor::new(encoded.clone()), distance) + .read_to_end(&mut reference) + .unwrap(); + assert_eq!(reference, data, "reference mismatch at distance {distance}"); + // arca decodes byte-identically at any chunking. + for (out_chunk, in_chunk) in [(1, 1), (3, 7), (256, 5), (65536, 65536)] { + assert_eq!( + arca_decode(distance, &encoded, out_chunk, in_chunk), + data, + "arca mismatch at distance {distance}, out={out_chunk}, in={in_chunk}" + ); + } + } + } +} diff --git a/libarchive_oxide/src/filter/mod.rs b/libarchive_oxide/src/filter/mod.rs index d30b145..130f323 100644 --- a/libarchive_oxide/src/filter/mod.rs +++ b/libarchive_oxide/src/filter/mod.rs @@ -4,6 +4,10 @@ //! Compression filter implementations and runtime dispatch. +#[cfg(feature = "sevenz")] +pub(crate) mod bcj; +#[cfg(feature = "sevenz")] +pub(crate) mod delta; pub mod gzip; #[cfg(all(feature = "lz4", not(feature = "native-codecs")))] pub(crate) mod lz4; diff --git a/libarchive_oxide/src/lib.rs b/libarchive_oxide/src/lib.rs index 76421bb..f0da4ec 100644 --- a/libarchive_oxide/src/lib.rs +++ b/libarchive_oxide/src/lib.rs @@ -32,7 +32,7 @@ pub mod async_seek; #[cfg(feature = "async")] pub mod async_stream; mod cab; -#[cfg(any(feature = "zstd", feature = "lz4"))] +#[cfg(any(feature = "zstd", feature = "lz4", feature = "sevenz"))] mod codec_read; pub mod create; pub mod engine; diff --git a/libarchive_oxide/src/sevenz.rs b/libarchive_oxide/src/sevenz.rs index 71714b1..5a926b2 100644 --- a/libarchive_oxide/src/sevenz.rs +++ b/libarchive_oxide/src/sevenz.rs @@ -6,12 +6,12 @@ //! //! Readers parse every folder's full coder graph (any number of coders, bind //! pairs, and packed streams) so listing works for any archive. Decoding is -//! limited to linear LZMA2/LZMA chains over a single pack stream: a folder built -//! that way streams normally, while richer graphs (BCJ2, `PPMd`, multi-coder -//! filter chains) list but report `Unsupported` on extraction. Exactly one -//! folder decode chain is live at a time, so memory stays bounded regardless of -//! folder count. Writers still emit a single solid LZMA2 folder. BCJ, delta, and -//! AES decode are not yet implemented. +//! limited to linear chains over a single pack stream whose coders are each +//! LZMA2/LZMA or a decode-only byte filter (delta, BCJ): such a folder streams +//! normally, while richer graphs (BCJ2, `PPMd`, multi-pack) list but report +//! `Unsupported` on extraction. Exactly one folder decode chain is live at a +//! time, so memory stays bounded regardless of folder count. Writers still emit +//! a single solid LZMA2 folder. AES decode is not yet implemented. use std::io::{Read, Seek, SeekFrom, Take, Write}; @@ -20,6 +20,9 @@ use libarchive_oxide_core::{ Extension, Limits, Owner, PathEncoding, Timestamp, }; +use crate::codec_read::CodecReader; +use crate::filter::bcj::{BcjDecoder, BranchKind}; +use crate::filter::delta::DeltaDecoder; use crate::{ReaderEvent, StreamError}; type Result = core::result::Result; @@ -65,6 +68,24 @@ const K_START_POS: u8 = 0x18; const METHOD_LZMA2: u8 = 0x21; /// LZMA (v1) coder method id (3 bytes) — how mainstream 7-Zip compresses encoded headers and folders. const METHOD_LZMA: [u8; 3] = [0x03, 0x01, 0x01]; +/// Delta filter coder method id (1 byte); its single property byte is `distance - 1`. +const METHOD_DELTA: u8 = 0x03; +/// BCJ x86 filter coder method id. +const METHOD_BCJ_X86: [u8; 4] = [0x03, 0x03, 0x01, 0x03]; +/// BCJ PowerPC filter coder method id. +const METHOD_BCJ_PPC: [u8; 4] = [0x03, 0x03, 0x02, 0x05]; +/// BCJ IA-64 filter coder method id. +const METHOD_BCJ_IA64: [u8; 4] = [0x03, 0x03, 0x04, 0x01]; +/// BCJ ARM filter coder method id. +const METHOD_BCJ_ARM: [u8; 4] = [0x03, 0x03, 0x05, 0x01]; +/// BCJ ARM-Thumb filter coder method id. +const METHOD_BCJ_ARMT: [u8; 4] = [0x03, 0x03, 0x07, 0x01]; +/// BCJ SPARC filter coder method id. +const METHOD_BCJ_SPARC: [u8; 4] = [0x03, 0x03, 0x08, 0x05]; +/// BCJ ARM64 filter coder method id (1 byte). +const METHOD_BCJ_ARM64: u8 = 0x0A; +/// BCJ RISC-V filter coder method id (1 byte). +const METHOD_BCJ_RISCV: u8 = 0x0B; /// `FILE_ATTRIBUTE_DIRECTORY`. const ATTR_DIRECTORY: u32 = 0x10; @@ -87,19 +108,31 @@ const WRITER_PRESET: u32 = 6; // ════════════════════════════════════════════════════════════════════════════════════════════════ /// One coder's decode method as parsed from a folder graph. Decoding supports LZMA2 (what this -/// crate writes) and plain LZMA (what 7-Zip and `sevenz-rust2` use for compressed/encoded headers -/// and folders). Every other method id (BCJ, delta, `PPMd`, Deflate, `BZip2`, Zstd, BCJ2, …) parses -/// into [`CoderMethod::Unsupported`] so listing works, but its payload cannot be decoded here. +/// crate writes), plain LZMA (what 7-Zip and `sevenz-rust2` use for compressed/encoded headers and +/// folders), and the decode-only delta and BCJ byte filters. Every other method id (`PPMd`, +/// Deflate, `BZip2`, Zstd, BCJ2, …) parses into [`CoderMethod::Unsupported`] so listing works, but +/// its payload cannot be decoded here. #[derive(Debug, Clone, Copy)] enum CoderMethod { /// LZMA2, carrying its one-byte dictionary-size property. Lzma2 { dict_prop: u8 }, /// LZMA (v1), carrying its 5 property bytes: `lc/lp/pb` byte + little-endian `u32` dict size. Lzma { props: [u8; 5] }, + /// A decode-only byte-to-byte filter (delta or BCJ) applied after its inner coder. + Filter(SevenFilter), /// A coder whose metadata can be listed but whose payload cannot be decoded. Unsupported, } +/// A 7z transform filter that decodes in place after the coder feeding it. +#[derive(Debug, Clone, Copy)] +enum SevenFilter { + /// Delta filter with a byte `distance` in `1..=256`. + Delta { distance: usize }, + /// Branch/Call/Jump filter for a given instruction family, with a running start position. + Bcj { kind: BranchKind, start: usize }, +} + /// One coder in a folder's graph, with its input/output stream arity. #[derive(Debug, Clone, Copy)] struct Coder { @@ -428,8 +461,9 @@ impl HeaderParser { /// One stage of a folder's decode chain, recursively wrapping the stage below it. The innermost /// stage is the raw pack stream (`Source`); each coder in the folder's decode order wraps the -/// stage below with its own reader, keeping dispatch static (no trait objects). Only LZMA/LZMA2 -/// stages exist today; a folder is built as a chain only when it is a linear LZMA/LZMA2 graph. +/// stage below with its own reader, keeping dispatch static (no trait objects). Stages are +/// LZMA/LZMA2 coders or decode-only byte filters (delta, BCJ); a folder is built as a chain only +/// when it is a linear graph of such coders over one pack stream. enum SevenStage { /// The base pack stream, bounded to the folder's packed bytes. Source(Take), @@ -437,6 +471,10 @@ enum SevenStage { Lzma2(Box>>), /// An LZMA (v1) coder reading the stage below. Lzma(Box>>), + /// A delta decode filter reading the stage below. + Delta(Box, DeltaDecoder>>), + /// A BCJ decode filter reading the stage below. + Bcj(Box, BcjDecoder>>), } enum SevenInput { @@ -451,6 +489,8 @@ impl SevenStage { Self::Source(take) => take.get_ref(), Self::Lzma2(reader) => reader.inner().source_ref(), Self::Lzma(reader) => reader.inner().source_ref(), + Self::Delta(reader) => reader.get_ref().source_ref(), + Self::Bcj(reader) => reader.get_ref().source_ref(), } } @@ -460,6 +500,8 @@ impl SevenStage { Self::Source(take) => take.into_inner(), Self::Lzma2(reader) => reader.into_inner().into_source(), Self::Lzma(reader) => reader.into_inner().into_source(), + Self::Delta(reader) => reader.into_inner().into_source(), + Self::Bcj(reader) => reader.into_inner().into_source(), } } } @@ -487,6 +529,8 @@ impl Read for SevenStage { Self::Source(take) => take.read(output), Self::Lzma2(reader) => reader.read(output), Self::Lzma(reader) => reader.read(output), + Self::Delta(reader) => reader.read(output), + Self::Bcj(reader) => reader.read(output), } } } @@ -1227,6 +1271,12 @@ fn wrap_coder( .map_err(|_| seven_error(ErrorKind::Malformed, "LZMA decoder setup failed"))?, ))) }, + CoderMethod::Filter(SevenFilter::Delta { distance }) => Ok(SevenStage::Delta(Box::new( + CodecReader::new(inner, DeltaDecoder::new(distance), "7z-delta"), + ))), + CoderMethod::Filter(SevenFilter::Bcj { kind, start }) => Ok(SevenStage::Bcj(Box::new( + CodecReader::new(inner, BcjDecoder::new(kind, start), "7z-bcj"), + ))), CoderMethod::Unsupported => Err(seven_error( ErrorKind::Unsupported, "payload coder is unsupported", @@ -1604,11 +1654,51 @@ fn classify_method(codec: &[u8], props: &[u8]) -> Result { let mut p = [0u8; 5]; p.copy_from_slice(props); Ok(CoderMethod::Lzma { props: p }) + } else if codec == [METHOD_DELTA] { + // The delta filter stores one property byte: `distance - 1`. An absent + // property (rare) means distance 1; any other length is not a valid delta. + let distance = match props { + [] => 1, + [prop] => usize::from(*prop) + 1, + _ => return Ok(CoderMethod::Unsupported), + }; + Ok(CoderMethod::Filter(SevenFilter::Delta { distance })) + } else if let Some(kind) = bcj_branch_kind(codec) { + // BCJ filters carry either no property or a 4-byte little-endian start offset. + let start = match props { + [] => 0, + [a, b, c, d] => usize_of(u64::from(u32::from_le_bytes([*a, *b, *c, *d])))?, + _ => return Ok(CoderMethod::Unsupported), + }; + Ok(CoderMethod::Filter(SevenFilter::Bcj { kind, start })) } else { Ok(CoderMethod::Unsupported) } } +/// Maps a BCJ coder method id onto its [`BranchKind`], or `None` if it is not a BCJ filter. +fn bcj_branch_kind(codec: &[u8]) -> Option { + if codec == METHOD_BCJ_X86 { + Some(BranchKind::X86) + } else if codec == METHOD_BCJ_ARM { + Some(BranchKind::Arm) + } else if codec == METHOD_BCJ_ARMT { + Some(BranchKind::ArmThumb) + } else if codec == [METHOD_BCJ_ARM64] { + Some(BranchKind::Arm64) + } else if codec == METHOD_BCJ_PPC { + Some(BranchKind::Ppc) + } else if codec == METHOD_BCJ_SPARC { + Some(BranchKind::Sparc) + } else if codec == METHOD_BCJ_IA64 { + Some(BranchKind::Ia64) + } else if codec == [METHOD_BCJ_RISCV] { + Some(BranchKind::RiscV) + } else { + None + } +} + /// Resolves a folder's coder graph into a topological decode order (coder indices, /// dependencies first). Detects cycles (DFS in-progress marks) and stream overlaps — an input fed /// by more than one source, an input fed by none, or an output bound more than once — all reported diff --git a/libarchive_oxide/tests/sevenz_differential.rs b/libarchive_oxide/tests/sevenz_differential.rs index 58dba42..bdabc9b 100644 --- a/libarchive_oxide/tests/sevenz_differential.rs +++ b/libarchive_oxide/tests/sevenz_differential.rs @@ -19,7 +19,8 @@ clippy::expect_used, clippy::panic, clippy::doc_markdown, - clippy::many_single_char_names + clippy::many_single_char_names, + clippy::cast_possible_truncation )] use std::io::Cursor; @@ -30,6 +31,7 @@ use libarchive_oxide_core::{ArchivePath, EntryKind, EntryMetadata, FormatId, Lim use sevenz_rust2::{ ArchiveEntry, ArchiveReader as SevenReader, ArchiveWriter as SevenWriter, EncoderConfiguration, EncoderMethod, Password, SourceReader, + encoder_options::{DeltaOptions, EncoderOptions}, }; mod common; @@ -272,3 +274,93 @@ fn arca_reads_sevenz_rust2_compressed_header() { assert_eq!(g.content(), e.1.as_slice()); } } + +/// Deterministic pseudo-random bytes; branch opcodes occur naturally so the BCJ filters transform. +fn pseudo_random(len: usize) -> Vec { + let mut state: u64 = 0xdead_beef_cafe_0007; + (0..len) + .map(|_| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + (state >> 24) as u8 + }) + .collect() +} + +/// Encodes one file through `sevenz-rust2` with an explicit content-coder chain. The chain is given +/// innermost-first (the compressor, then the filters that wrap it), matching `set_content_methods`. +fn sevenz_with_methods(methods: Vec, name: &str, data: &[u8]) -> Vec { + let mut w = SevenWriter::new(Cursor::new(Vec::new())).unwrap(); + w.set_content_methods(methods); + w.push_archive_entry(ArchiveEntry::new_file(name), Some(data)) + .unwrap(); + w.finish().unwrap().into_inner() +} + +/// A folder whose coder graph chains the delta filter with LZMA2 must decode in arca: LZMA2 +/// decompresses the pack stream, then the delta stage reconstructs the original bytes. `sevenz-rust2` +/// (using `lzma_rust2`'s independent delta implementation) is the producer; arca is the consumer. +#[test] +fn arca_reads_sevenz_rust2_delta_lzma2() { + let data = pseudo_random(40_003); + for distance in [1u32, 4, 256] { + let methods = vec![ + EncoderConfiguration::new(EncoderMethod::LZMA2), + EncoderConfiguration::new(EncoderMethod::DELTA_FILTER) + .with_options(EncoderOptions::Delta(DeltaOptions::from_distance(distance))), + ]; + let bytes = sevenz_with_methods(methods, "delta.bin", &data); + // sevenz-rust2 must agree the archive is a valid delta+LZMA2 chain it can also read back. + let mut reader = SevenReader::new(Cursor::new(bytes.clone()), Password::empty()) + .expect("sevenz-rust2 opens its own delta+LZMA2 archive"); + assert_eq!(reader.read_file("delta.bin").unwrap(), data); + + let got = read_with_arca(&bytes); + let expected = vec![EntryShape::new( + b"delta.bin".to_vec(), + EntryKind::File, + data.clone(), + )]; + assert_eq!( + got, expected, + "arca delta+LZMA2 mismatch at distance {distance}" + ); + } +} + +/// The same shape with a BCJ branch filter: LZMA2 over a BCJ-filtered payload. arca must resolve the +/// two-coder graph, decompress LZMA2, then invert the branch transform. Exercised for the x86 +/// (stateful) filter and a couple of fixed-stride RISC families. +#[test] +fn arca_reads_sevenz_rust2_bcj_lzma2() { + let data = pseudo_random(60_011); + let filters = [ + ("x86", EncoderMethod::BCJ_X86_FILTER), + ("arm", EncoderMethod::BCJ_ARM_FILTER), + ("ppc", EncoderMethod::BCJ_PPC_FILTER), + ("sparc", EncoderMethod::BCJ_SPARC_FILTER), + ]; + for (label, method) in filters { + let methods = vec![ + EncoderConfiguration::new(EncoderMethod::LZMA2), + EncoderConfiguration::new(method), + ]; + let bytes = sevenz_with_methods(methods, "bcj.bin", &data); + let mut reader = SevenReader::new(Cursor::new(bytes.clone()), Password::empty()) + .unwrap_or_else(|e| panic!("sevenz-rust2 opens its own {label} archive: {e}")); + assert_eq!( + reader.read_file("bcj.bin").unwrap(), + data, + "{label} self-read" + ); + + let got = read_with_arca(&bytes); + let expected = vec![EntryShape::new( + b"bcj.bin".to_vec(), + EntryKind::File, + data.clone(), + )]; + assert_eq!(got, expected, "arca BCJ+LZMA2 mismatch for {label}"); + } +} From 870ec6f7e95f96fc3185b6509406e5c3b43b095d Mon Sep 17 00:00:00 2001 From: Yasunobu <42543015+P4suta@users.noreply.github.com> Date: Fri, 24 Jul 2026 01:02:46 +0900 Subject: [PATCH 4/8] feat(7z): reuse Deflate/BZip2/Zstd codecs in the coder graph [RM-303] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 4 of the 7z coder-graph landing: decode the general-purpose 7z content coders by reusing arca's existing codecs through the shared PipelineCodec dispatch, wrapped as a new SevenStage::Pipeline chain stage (no second parser; ADR-0004 preserved). One active folder decoder, static dispatch, and #![forbid(unsafe_code)] all hold — the codecs live in existing deps. - core: add FilterId::Deflate (raw RFC 1951, no framing, no probe signature). - gzip: add RawInflateDecoder, a bare raw-DEFLATE Codec over the same miniz_oxide raw-inflate core GzipDecoder sits on, with a decoded_total guard. - pipeline_codec: add the FilterId::Deflate arm (sevenz-gated). - sevenz: classify methods 04 01 08 (Deflate), 04 02 02 (BZip2), 04 F7 11 01 (Zstd) into SevenFilter::Pipeline; BZip2/Zstd list-only when their features are off. wrap_coder builds the pipeline stage and bounds BZip2/Zstd working sets against codec_memory (validate_pipeline_memory), mirroring the LZMA dictionary check; the native zstd backend additionally caps the window. - tests: differential decode of sevenz-rust2 Deflate/BZip2/Zstd folders, the latter two gated on arca's bzip2/zstd features; dev-dep sevenz-rust2 gains the deflate + zstd features to produce those folders. Verified: cargo build -p libarchive_oxide --features sevenz, "sevenz aes", and --no-default-features --features sevenz; cargo build -p xtask; xtask codec-policy (portable stays C/FFI-free) and no-dyn; cargo test --features sevenz (and "sevenz aes") --test sevenz_differential (11 passed); clippy clean on touched files. Cross/qemu and macOS matrices deferred to CI. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 2 + libarchive_oxide-core/src/filter.rs | 5 + libarchive_oxide/Cargo.toml | 4 +- libarchive_oxide/src/filter/gzip.rs | 151 ++++++++++++++++++ libarchive_oxide/src/pipeline_codec.rs | 11 ++ libarchive_oxide/src/sevenz.rs | 106 +++++++++++- libarchive_oxide/tests/sevenz_differential.rs | 60 +++++++ 7 files changed, 331 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cd3b7da..698376a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -852,12 +852,14 @@ dependencies = [ "bzip2", "cbc", "crc32fast", + "flate2", "getrandom", "js-sys", "lzma-rust2", "ppmd-rust", "sha2", "wasm-bindgen", + "zstd", ] [[package]] diff --git a/libarchive_oxide-core/src/filter.rs b/libarchive_oxide-core/src/filter.rs index c9d87e8..2f43f1b 100644 --- a/libarchive_oxide-core/src/filter.rs +++ b/libarchive_oxide-core/src/filter.rs @@ -20,6 +20,11 @@ pub enum FilterId { Lz4, /// Bzip2 stream. Bzip2, + /// Raw DEFLATE (RFC 1951) with no framing — the 7z Deflate coder stores bare + /// deflate blocks with no gzip header, trailer, or checksum. Has no stream + /// signature, so it never participates in [`FilterId::probe`]; it is only ever + /// selected structurally from a 7z folder's coder graph. + Deflate, } impl FilterId { diff --git a/libarchive_oxide/Cargo.toml b/libarchive_oxide/Cargo.toml index ee7d7ac..d5b70d8 100644 --- a/libarchive_oxide/Cargo.toml +++ b/libarchive_oxide/Cargo.toml @@ -120,7 +120,9 @@ ruzstd = "0.8.2" zstd-codec = { package = "zstd", version = "0.13.3" } # Independent SHA-256 reference for OCI layer digest/diffID tests. sha2 = "0.11" -sevenz-rust2 = "0.21.3" +# `bzip2` is on by default; `deflate` and `zstd` are enabled so the 7z differential +# tests can produce Deflate/Zstd folders for arca's reuse-codec decode path. +sevenz-rust2 = { version = "0.21.3", features = ["deflate", "zstd"] } zip = { version = "8.6.0", default-features = false, features = ["deflate", "aes-crypto", "bzip2", "zstd", "lzma"] } # Independent tar/ar producers and consumers used only for RM-304 interop tests. tar = { version = "0.4", default-features = false } diff --git a/libarchive_oxide/src/filter/gzip.rs b/libarchive_oxide/src/filter/gzip.rs index d1a87ba..0c6418d 100644 --- a/libarchive_oxide/src/filter/gzip.rs +++ b/libarchive_oxide/src/filter/gzip.rs @@ -362,6 +362,157 @@ impl Codec for GzipDecoder { } } +/// Raw DEFLATE (RFC 1951) inflate with no framing. +/// +/// The gzip decoder above wraps this same `miniz_oxide` raw-inflate core in RFC 1952 +/// header/trailer handling; the 7z Deflate coder, by contrast, stores bare deflate +/// blocks with no header, trailer, or checksum, so it drives the raw core directly. +/// The window is a fixed 32 KiB, so the decoder's working set is inherently bounded; +/// a `decoded_total` guard adds decompression-bomb defense on top. +#[cfg(feature = "sevenz")] +pub(crate) struct RawInflateDecoder { + inflate: Box, + limits: Limits, + decoded_total: u64, + done: bool, +} + +#[cfg(feature = "sevenz")] +impl core::fmt::Debug for RawInflateDecoder { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("RawInflateDecoder") + .field("done", &self.done) + .finish_non_exhaustive() + } +} + +#[cfg(feature = "sevenz")] +impl RawInflateDecoder { + /// Creates a raw-DEFLATE decompressor with mandatory resource limits. + #[must_use] + pub(crate) fn new(limits: Limits) -> Self { + Self { + inflate: Box::new(InflateState::new(DataFormat::Raw)), + limits, + decoded_total: 0, + done: false, + } + } + + fn account_output(&mut self, produced: usize) -> Result<()> { + if produced == 0 { + return Ok(()); + } + let next = self + .decoded_total + .checked_add(produced as u64) + .ok_or_else(|| gzip_error(ErrorKind::Limit, "decoded byte count overflow"))?; + if self + .limits + .decoded_total() + .is_some_and(|maximum| next > maximum) + { + return Err(gzip_error( + ErrorKind::Limit, + "decoded stream exceeds configured limit", + )); + } + self.decoded_total = next; + Ok(()) + } +} + +#[cfg(feature = "sevenz")] +impl Codec for RawInflateDecoder { + fn process( + &mut self, + input: &[u8], + output: &mut [u8], + end: EndOfInput, + ) -> core::result::Result { + if self.done { + if input.is_empty() { + return Ok(CodecStep { + consumed: 0, + produced: 0, + status: CodecStatus::Done, + }); + } + return Err(gzip_error( + ErrorKind::Malformed, + "data follows the completed deflate stream", + )); + } + // `miniz_oxide`'s streaming `inflate` errors on empty input under `MZFlush::None`, so an + // empty chunk is handled explicitly: flush the tail at end-of-input, otherwise wait. + if input.is_empty() { + if !matches!(end, EndOfInput::End) { + return Ok(CodecStep { + consumed: 0, + produced: 0, + status: CodecStatus::NeedInput, + }); + } + let res = inflate(&mut self.inflate, &[], output, MZFlush::Finish); + let produced = res.bytes_written; + self.account_output(produced)?; + return match res.status { + Ok(MZStatus::StreamEnd) => { + self.done = true; + CodecStep { + consumed: 0, + produced, + status: CodecStatus::Done, + } + .validate(input.len(), output.len()) + }, + Ok(_) if produced > 0 => CodecStep { + consumed: 0, + produced, + status: CodecStatus::NeedOutput, + } + .validate(input.len(), output.len()), + _ => Err(gzip_error(ErrorKind::Malformed, "truncated deflate stream")), + }; + } + + let res = inflate(&mut self.inflate, input, output, MZFlush::None); + let consumed = res.bytes_consumed; + let produced = res.bytes_written; + self.account_output(produced)?; + match res.status { + Ok(MZStatus::StreamEnd) => { + self.done = true; + return CodecStep { + consumed, + produced, + status: CodecStatus::Done, + } + .validate(input.len(), output.len()); + }, + Ok(_) | Err(MZError::Buf) => {}, + Err(_) => return Err(gzip_error(ErrorKind::Malformed, "inflate error")), + } + // Report progress in a shape the driver can always advance on: output-full asks for more + // output; input-drained asks for more input; a genuine no-progress stall is malformed. + let status = if produced == output.len() { + CodecStatus::NeedOutput + } else if consumed == input.len() { + CodecStatus::NeedInput + } else if consumed == 0 && produced == 0 { + return Err(gzip_error(ErrorKind::Malformed, "deflate stream stalled")); + } else { + CodecStatus::NeedOutput + }; + CodecStep { + consumed, + produced, + status, + } + .validate(input.len(), output.len()) + } +} + const GZIP_HEADER: [u8; 10] = [0x1f, 0x8b, 0x08, 0x00, 0, 0, 0, 0, 0x00, 0xff]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/libarchive_oxide/src/pipeline_codec.rs b/libarchive_oxide/src/pipeline_codec.rs index b18ca85..f91aebd 100644 --- a/libarchive_oxide/src/pipeline_codec.rs +++ b/libarchive_oxide/src/pipeline_codec.rs @@ -23,6 +23,10 @@ pub(crate) enum PipelineCodec { Gzip(ExternalDecoder), #[cfg(not(feature = "native-codecs"))] Gzip(Box), + /// Raw DEFLATE (no gzip framing) — the 7z Deflate coder. Backed by the same + /// `miniz_oxide` raw-inflate core the gzip decoder sits on. + #[cfg(feature = "sevenz")] + Deflate(Box), #[cfg(feature = "bzip2")] Bzip2(ExternalDecoder), #[cfg(all(feature = "zstd", feature = "native-codecs"))] @@ -55,6 +59,10 @@ impl PipelineCodec { Ok(Self::Gzip(Box::new(GzipDecoder::new(limits)))) } }, + #[cfg(feature = "sevenz")] + FilterId::Deflate => Ok(Self::Deflate(Box::new( + crate::filter::gzip::RawInflateDecoder::new(limits), + ))), FilterId::Bzip2 => { #[cfg(feature = "bzip2")] { @@ -133,6 +141,8 @@ impl PipelineCodec { Self::Gzip(codec) => codec.process(input, output, end), #[cfg(not(feature = "native-codecs"))] Self::Gzip(codec) => codec.process(input, output, end), + #[cfg(feature = "sevenz")] + Self::Deflate(codec) => codec.process(input, output, end), #[cfg(feature = "bzip2")] Self::Bzip2(codec) => codec.process(input, output, end), #[cfg(feature = "zstd")] @@ -213,6 +223,7 @@ fn disabled(filter: FilterId) -> ArchiveError { const fn filter_name(filter: FilterId) -> &'static str { match filter { FilterId::Gzip => "gzip", + FilterId::Deflate => "deflate", FilterId::Bzip2 => "bzip2", FilterId::Zstd => "zstd", FilterId::Xz => "xz", diff --git a/libarchive_oxide/src/sevenz.rs b/libarchive_oxide/src/sevenz.rs index 5a926b2..a3d0c25 100644 --- a/libarchive_oxide/src/sevenz.rs +++ b/libarchive_oxide/src/sevenz.rs @@ -7,14 +7,16 @@ //! Readers parse every folder's full coder graph (any number of coders, bind //! pairs, and packed streams) so listing works for any archive. Decoding is //! limited to linear chains over a single pack stream whose coders are each -//! LZMA2/LZMA or a decode-only byte filter (delta, BCJ): such a folder streams -//! normally, while richer graphs (BCJ2, `PPMd`, multi-pack) list but report -//! `Unsupported` on extraction. Exactly one folder decode chain is live at a +//! LZMA2/LZMA, a decode-only byte filter (delta, BCJ), or a general-purpose +//! coder reused from the shared codec dispatch (Deflate, `BZip2`, Zstd): such a +//! folder streams normally, while richer graphs (BCJ2, `PPMd`, multi-pack) list +//! but report `Unsupported` on extraction. Exactly one folder decode chain is live at a //! time, so memory stays bounded regardless of folder count. Writers still emit //! a single solid LZMA2 folder. AES decode is not yet implemented. use std::io::{Read, Seek, SeekFrom, Take, Write}; +use libarchive_oxide_core::filter::FilterId; use libarchive_oxide_core::{ ArchiveError, ArchiveMetadata, ArchivePath, EntryKind, EntryMetadata, EntryTimes, ErrorKind, Extension, Limits, Owner, PathEncoding, Timestamp, @@ -23,6 +25,7 @@ use libarchive_oxide_core::{ use crate::codec_read::CodecReader; use crate::filter::bcj::{BcjDecoder, BranchKind}; use crate::filter::delta::DeltaDecoder; +use crate::pipeline_codec::PipelineCodec; use crate::{ReaderEvent, StreamError}; type Result = core::result::Result; @@ -86,6 +89,21 @@ const METHOD_BCJ_SPARC: [u8; 4] = [0x03, 0x03, 0x08, 0x05]; const METHOD_BCJ_ARM64: u8 = 0x0A; /// BCJ RISC-V filter coder method id (1 byte). const METHOD_BCJ_RISCV: u8 = 0x0B; +/// Deflate coder method id (3 bytes) — RAW DEFLATE (RFC 1951), no gzip/zlib framing. +const METHOD_DEFLATE: [u8; 3] = [0x04, 0x01, 0x08]; +/// `BZip2` coder method id (3 bytes). +const METHOD_BZIP2: [u8; 3] = [0x04, 0x02, 0x02]; +/// Zstandard coder method id (4 bytes), as used by the 7-Zip-zstd fork and `sevenz-rust2`. +const METHOD_ZSTD: [u8; 4] = [0x04, 0xF7, 0x11, 0x01]; + +/// Peak decompression working set for a bzip2 stream at the largest (900 KiB) block size. +/// libbzip2 documents ~3.7 MiB for `-9` decompression; rounded up. A folder's bzip2 coder is +/// refused when the configured codec-memory budget cannot hold it. +const BZIP2_DECODE_WORKSPACE: usize = 4 * 1024 * 1024; +/// Smallest zstd window (the format's minimum `Window_Log` of 10 → 1 KiB) the decoder must be +/// allowed to allocate. The native zstd backend additionally caps the frame window to the +/// codec-memory budget inside [`PipelineCodec`]; this floor rejects a nonsensically tiny budget. +const ZSTD_MIN_WINDOW: usize = 1024; /// `FILE_ATTRIBUTE_DIRECTORY`. const ATTR_DIRECTORY: u32 = 0x10; @@ -109,9 +127,9 @@ const WRITER_PRESET: u32 = 6; /// One coder's decode method as parsed from a folder graph. Decoding supports LZMA2 (what this /// crate writes), plain LZMA (what 7-Zip and `sevenz-rust2` use for compressed/encoded headers and -/// folders), and the decode-only delta and BCJ byte filters. Every other method id (`PPMd`, -/// Deflate, `BZip2`, Zstd, BCJ2, …) parses into [`CoderMethod::Unsupported`] so listing works, but -/// its payload cannot be decoded here. +/// folders), the decode-only delta and BCJ byte filters, and the general-purpose Deflate/BZip2/Zstd +/// coders reused from the shared [`PipelineCodec`] dispatch. Every other method id (`PPMd`, BCJ2, …) +/// parses into [`CoderMethod::Unsupported`] so listing works, but its payload cannot be decoded here. #[derive(Debug, Clone, Copy)] enum CoderMethod { /// LZMA2, carrying its one-byte dictionary-size property. @@ -124,13 +142,18 @@ enum CoderMethod { Unsupported, } -/// A 7z transform filter that decodes in place after the coder feeding it. +/// A 7z coder that arca decodes by reusing an existing codec. Delta and BCJ are decode-only byte +/// filters applied in place after the coder feeding them; [`SevenFilter::Pipeline`] instead reuses +/// a caller-driven [`PipelineCodec`] (Deflate/BZip2/Zstd) as a decompression coder in the chain. #[derive(Debug, Clone, Copy)] enum SevenFilter { /// Delta filter with a byte `distance` in `1..=256`. Delta { distance: usize }, /// Branch/Call/Jump filter for a given instruction family, with a running start position. Bcj { kind: BranchKind, start: usize }, + /// A general-purpose decompression coder reused from the shared [`PipelineCodec`] dispatch: + /// 7z Deflate (raw), `BZip2`, or Zstd, identified by its [`FilterId`]. + Pipeline { filter: FilterId }, } /// One coder in a folder's graph, with its input/output stream arity. @@ -475,6 +498,9 @@ enum SevenStage { Delta(Box, DeltaDecoder>>), /// A BCJ decode filter reading the stage below. Bcj(Box, BcjDecoder>>), + /// A general-purpose decompression coder (Deflate/BZip2/Zstd) reused from [`PipelineCodec`], + /// reading the stage below. + Pipeline(Box, PipelineCodec>>), } enum SevenInput { @@ -491,6 +517,7 @@ impl SevenStage { Self::Lzma(reader) => reader.inner().source_ref(), Self::Delta(reader) => reader.get_ref().source_ref(), Self::Bcj(reader) => reader.get_ref().source_ref(), + Self::Pipeline(reader) => reader.get_ref().source_ref(), } } @@ -502,6 +529,7 @@ impl SevenStage { Self::Lzma(reader) => reader.into_inner().into_source(), Self::Delta(reader) => reader.into_inner().into_source(), Self::Bcj(reader) => reader.into_inner().into_source(), + Self::Pipeline(reader) => reader.into_inner().into_source(), } } } @@ -531,6 +559,7 @@ impl Read for SevenStage { Self::Lzma(reader) => reader.read(output), Self::Delta(reader) => reader.read(output), Self::Bcj(reader) => reader.read(output), + Self::Pipeline(reader) => reader.read(output), } } } @@ -1277,6 +1306,15 @@ fn wrap_coder( CoderMethod::Filter(SevenFilter::Bcj { kind, start }) => Ok(SevenStage::Bcj(Box::new( CodecReader::new(inner, BcjDecoder::new(kind, start), "7z-bcj"), ))), + CoderMethod::Filter(SevenFilter::Pipeline { filter }) => { + validate_pipeline_memory(filter, limits)?; + let codec = PipelineCodec::new(filter, limits).map_err(StreamError::archive)?; + Ok(SevenStage::Pipeline(Box::new(CodecReader::new( + inner, + codec, + "7z-pipeline", + )))) + }, CoderMethod::Unsupported => Err(seven_error( ErrorKind::Unsupported, "payload coder is unsupported", @@ -1284,6 +1322,30 @@ fn wrap_coder( } } +/// Bounds a reused [`PipelineCodec`] coder's working set against the configured codec-memory +/// budget, mirroring [`validate_dictionary`] for LZMA. Deflate has a fixed 32 KiB window and is +/// always within budget; `BZip2` and Zstd are refused when the budget cannot hold their workspace. +fn validate_pipeline_memory( + filter: FilterId, + limits: Limits, +) -> core::result::Result<(), StreamError> { + let Some(budget) = limits.codec_memory() else { + return Ok(()); + }; + let needed = match filter { + FilterId::Bzip2 => BZIP2_DECODE_WORKSPACE, + FilterId::Zstd => ZSTD_MIN_WINDOW, + _ => return Ok(()), + }; + if budget < needed { + return Err(seven_error( + ErrorKind::Limit, + "7z codec workspace exceeds codec-memory limit", + )); + } + Ok(()) +} + fn validate_dictionary(dictionary: u32, limits: Limits) -> core::result::Result<(), StreamError> { if limits .codec_memory() @@ -1671,6 +1733,36 @@ fn classify_method(codec: &[u8], props: &[u8]) -> Result { _ => return Ok(CoderMethod::Unsupported), }; Ok(CoderMethod::Filter(SevenFilter::Bcj { kind, start })) + } else if codec == METHOD_DEFLATE { + // Raw DEFLATE, always available under the `sevenz` feature (it shares the gzip codec's + // `miniz_oxide` core). The coder carries no decode-relevant properties. + Ok(CoderMethod::Filter(SevenFilter::Pipeline { + filter: FilterId::Deflate, + })) + } else if codec == METHOD_BZIP2 { + // BZip2 lists on every build but only decodes when the `bzip2` codec is compiled in. + #[cfg(feature = "bzip2")] + { + Ok(CoderMethod::Filter(SevenFilter::Pipeline { + filter: FilterId::Bzip2, + })) + } + #[cfg(not(feature = "bzip2"))] + { + Ok(CoderMethod::Unsupported) + } + } else if codec == METHOD_ZSTD { + // Zstd lists on every build but only decodes when the `zstd` codec is compiled in. + #[cfg(feature = "zstd")] + { + Ok(CoderMethod::Filter(SevenFilter::Pipeline { + filter: FilterId::Zstd, + })) + } + #[cfg(not(feature = "zstd"))] + { + Ok(CoderMethod::Unsupported) + } } else { Ok(CoderMethod::Unsupported) } diff --git a/libarchive_oxide/tests/sevenz_differential.rs b/libarchive_oxide/tests/sevenz_differential.rs index bdabc9b..e3efd4e 100644 --- a/libarchive_oxide/tests/sevenz_differential.rs +++ b/libarchive_oxide/tests/sevenz_differential.rs @@ -364,3 +364,63 @@ fn arca_reads_sevenz_rust2_bcj_lzma2() { assert_eq!(got, expected, "arca BCJ+LZMA2 mismatch for {label}"); } } + +/// A payload that mixes a highly compressible run with pseudo-random noise, so the general-purpose +/// coders below (Deflate/BZip2/Zstd) emit real, non-degenerate compressed folders. +fn mixed_payload() -> Vec { + let mut data = b"the quick brown fox jumps over the lazy dog\n".repeat(400); + data.extend(pseudo_random(20_000)); + data.extend(b"trailing repeated tail tail tail tail tail\n".repeat(120)); + data +} + +/// Round-trips one file through `sevenz-rust2` with a single content coder `method`, asserting the +/// producer can read its own archive and that arca reproduces the original bytes exactly. +#[track_caller] +fn assert_arca_reads_method(method: EncoderMethod, label: &str) { + let data = mixed_payload(); + let bytes = sevenz_with_methods( + vec![EncoderConfiguration::new(method)], + "payload.bin", + &data, + ); + let mut reader = SevenReader::new(Cursor::new(bytes.clone()), Password::empty()) + .unwrap_or_else(|e| panic!("sevenz-rust2 opens its own {label} archive: {e}")); + assert_eq!( + reader.read_file("payload.bin").unwrap(), + data, + "{label} self-read" + ); + + let got = read_with_arca(&bytes); + let expected = vec![EntryShape::new( + b"payload.bin".to_vec(), + EntryKind::File, + data.clone(), + )]; + assert_eq!(got, expected, "arca {label} decode mismatch"); +} + +/// The 7z Deflate coder (method `04 01 08`) is RAW DEFLATE — no gzip header, trailer, or checksum. +/// arca reuses the shared `PipelineCodec` raw-inflate arm (the same `miniz_oxide` core the gzip +/// decoder sits on) to decode it. Always available under the `sevenz` feature. +#[test] +fn arca_reads_sevenz_rust2_deflate() { + assert_arca_reads_method(EncoderMethod::DEFLATE, "deflate"); +} + +/// The 7z BZip2 coder (method `04 02 02`), reusing arca's existing BZip2 codec through the folder +/// coder graph. Gated on arca's `bzip2` feature; `sevenz-rust2`'s bzip2 support is on by default. +#[cfg(feature = "bzip2")] +#[test] +fn arca_reads_sevenz_rust2_bzip2() { + assert_arca_reads_method(EncoderMethod::BZIP2, "bzip2"); +} + +/// The 7z Zstd coder (method `04 F7 11 01`), reusing arca's existing Zstd codec (portable `ruzstd` +/// or native `compression-codecs`) through the folder coder graph. Gated on arca's `zstd` feature. +#[cfg(feature = "zstd")] +#[test] +fn arca_reads_sevenz_rust2_zstd() { + assert_arca_reads_method(EncoderMethod::ZSTD, "zstd"); +} From 90adcbd01912e403404eafb24433900ebb2cc405 Mon Sep 17 00:00:00 2001 From: Yasunobu <42543015+P4suta@users.noreply.github.com> Date: Fri, 24 Jul 2026 01:47:40 +0900 Subject: [PATCH 5/8] feat(7z): AES-256/SHA-256 decryption coder [RM-303] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the 7z AES-256/SHA-256 encryption coder (method 06F10701) to the single seek parser's coder graph, gated on the `aes` feature. * New `filter/aes7z.rs`: parses the coder properties (numCyclesPower/salt/IV; 0x3F external-key marker and out-of-range work factors list but do not decode), derives the key with 7-Zip's own KDF (a single SHA-256 context iterating salt || password || counter 2^ncp times, password encoded as UTF-16LE — the ZIP AE-2 path instead hashes raw bytes via PBKDF2-HMAC-SHA1), and decrypts with cbc::Decryptor and the stored IV. Trailing ciphertext padding is truncated against the coder's declared output size rather than erroring. * Thread Option from SeekArchiveReader through SevenZSeekReader::new and the seek dispatch site (previously dropped) down to the coder build. A missing password on an AES folder is a typed Unsupported error; secrets are never logged. * Retain per-substream CRC-32 (previously discarded in the digest reader) and verify it after decode; a mismatch is ErrorKind::Integrity, reported as "wrong password or corrupt data" on an encrypted folder. * Add `cbc` to the `aes` feature. Differential coverage vs sevenz-rust2 AES256SHA256 + Password: correct password decodes the encrypted header and content to plaintext; a wrong password trips Integrity; a missing password is Unsupported. Static dispatch, bounded memory, and #![forbid(unsafe_code)] are preserved; portable-codecs stays C/FFI-free. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 1 + libarchive_oxide/Cargo.toml | 6 +- libarchive_oxide/src/filter/aes7z.rs | 336 ++++++++++++++++++ libarchive_oxide/src/filter/mod.rs | 2 + libarchive_oxide/src/seek_stream.rs | 2 +- libarchive_oxide/src/sevenz.rs | 283 ++++++++++++--- libarchive_oxide/tests/sevenz_differential.rs | 102 ++++++ 7 files changed, 688 insertions(+), 44 deletions(-) create mode 100644 libarchive_oxide/src/filter/aes7z.rs diff --git a/Cargo.lock b/Cargo.lock index 698376a..c198bcf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -530,6 +530,7 @@ dependencies = [ "bzip2", "cap-fs-ext", "cap-std", + "cbc", "compression-codecs", "ctr", "flate2", diff --git a/libarchive_oxide/Cargo.toml b/libarchive_oxide/Cargo.toml index d5b70d8..845389a 100644 --- a/libarchive_oxide/Cargo.toml +++ b/libarchive_oxide/Cargo.toml @@ -56,8 +56,8 @@ bzip2 = [ zstd = ["dep:ruzstd"] xz = ["dep:lzma-rust2"] lz4 = ["dep:lz4_flex", "dep:twox-hash"] -# WinZip AES-256 AE-2. -aes = ["dep:aes", "dep:ctr", "dep:hmac", "dep:sha1", "dep:pbkdf2", "dep:subtle", "dep:getrandom"] +# WinZip AES-256 AE-2 (ZIP) and AES-256/SHA-256 (7z, via `cbc`). +aes = ["dep:aes", "dep:cbc", "dep:ctr", "dep:hmac", "dep:sha1", "dep:pbkdf2", "dep:subtle", "dep:getrandom"] # 7z single-folder LZMA2 and shared CRC-32. sevenz = ["dep:lzma-rust2", "gzip"] # Runtime-neutral and Tokio I/O adapters. Both drive the same sans-I/O @@ -85,6 +85,8 @@ xz-codec = { package = "xz2", version = "0.1.7", optional = true } lzma-rust2 = { version = "0.16.5", default-features = false, features = ["std", "encoder", "xz"], optional = true } # WinZip AE-2 requires SHA-1. aes = { version = "0.9", optional = true } +# 7z AES-256/SHA-256 uses CBC (ZIP AE-2 uses CTR via `ctr`). +cbc = { version = "0.2", optional = true } ctr = { version = "0.10", optional = true } hmac = { version = "0.13", optional = true } sha1 = { version = "0.11", optional = true } diff --git a/libarchive_oxide/src/filter/aes7z.rs b/libarchive_oxide/src/filter/aes7z.rs new file mode 100644 index 0000000..b457dd9 --- /dev/null +++ b/libarchive_oxide/src/filter/aes7z.rs @@ -0,0 +1,336 @@ +// SPDX-FileCopyrightText: 2026 libarchive_oxide contributors +// +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! 7z AES-256/SHA-256 decryption coder (method id `06 F1 07 01`) as a sans-I/O [`Codec`]. +//! +//! This is the 7-Zip encryption coder, distinct from the ZIP `WinZip` `AE-2` path +//! (`seek_stream::ZipAesDecoder`, AES-256-CTR + PBKDF2-HMAC-SHA1). The two differ +//! in three ways that matter for interoperability: +//! +//! * **Cipher mode.** 7z uses AES-256 in **CBC** with a stored IV; ZIP AE-2 uses +//! AES-256 in CTR. +//! * **Key derivation.** 7z runs a single SHA-256 context fed +//! `salt || password || counter` `2^numCyclesPower` times (7-Zip's own KDF); ZIP +//! uses PBKDF2-HMAC-SHA1 with 1000 iterations. +//! * **Password encoding.** 7z hashes the password as **UTF-16LE** code units; +//! ZIP AE-2 hashes the raw password bytes. The same user string therefore yields +//! different keys in the two formats. +//! +//! The decoder decrypts whole 16-byte blocks and buffers a partial input block +//! and a partial output block internally, so it composes with any input chunking +//! from the coder below it. The 7z encoder zero-pads the ciphertext up to a block +//! boundary; the coder's declared output size (`out_size`) caps how many decrypted +//! bytes are emitted, so that trailing padding is truncated rather than reported as +//! an error. The per-substream CRC-32 (verified by the folder reader) is what +//! ultimately distinguishes a correct password from a wrong one. + +use aes::cipher::{BlockModeDecrypt, KeyIvInit, array::Array}; +use libarchive_oxide_core::{ArchiveError, Codec, CodecStatus, CodecStep, EndOfInput, ErrorKind}; +use sha2::Digest; +use zeroize::Zeroize; + +/// AES block size in bytes. +const BLOCK: usize = 16; + +/// Largest accepted key-derivation work factor. The KDF runs `2^numCyclesPower` +/// SHA-256 rounds, so an attacker-supplied large power is a CPU-exhaustion `DoS` (and +/// `1 << power` also overflows for `power >= 64`). 7-Zip's own encoder never exceeds +/// this bound; a larger value only ever comes from a hostile archive. Keeping it below +/// 32 also makes the `1u64 << ncp` shift trivially safe. Mirrors `sevenz-rust2`. +const MAX_CYCLES_POWER: u8 = 24; + +type Aes256CbcDec = cbc::Decryptor; + +/// Parsed AES256SHA256 coder properties. Salt and IV are archive-public (they are +/// stored in the clear in the header), so this carries no secret material and is safe +/// to `Debug`. The derived key never lives here. +#[derive(Debug, Clone, Copy)] +pub(crate) struct AesParams { + ncp: u8, + salt: [u8; BLOCK], + salt_len: usize, + iv: [u8; BLOCK], +} + +impl AesParams { + /// Parses the coder property bytes for method `06 F1 07 01`. + /// + /// Returns `None` (→ the folder lists but is not decodable) for an external-key + /// marker (`numCyclesPower == 0x3F`), a work factor above [`MAX_CYCLES_POWER`], or + /// any malformed / out-of-range salt/IV framing. The property layout is: + /// `b0` = `numCyclesPower | (saltHi << 7) | (ivHi << 6)`, `b1` = + /// `(saltLow << 4) | ivLow`, then `saltSize` salt bytes and `ivSize` IV bytes. + pub(crate) fn parse(props: &[u8]) -> Option { + let b0 = *props.first()?; + let ncp = b0 & 0x3F; + // 0x3F is 7-Zip's "key supplied externally" marker; unsupported here. + if ncp == 0x3F || ncp > MAX_CYCLES_POWER { + return None; + } + // A one-byte property (some archives store the kEnd byte as a lone property) is + // treated as a zero second byte, matching mainstream decoders. + let b1 = props.get(1).copied().unwrap_or(0); + let iv_size = usize::from(((b0 >> 6) & 1) + (b1 & 0x0F)); + let salt_size = usize::from(((b0 >> 7) & 1) + (b1 >> 4)); + if salt_size > BLOCK || iv_size > BLOCK { + return None; + } + let end = 2usize.checked_add(salt_size)?.checked_add(iv_size)?; + if end > props.len() { + return None; + } + let mut salt = [0u8; BLOCK]; + salt[..salt_size].copy_from_slice(&props[2..2 + salt_size]); + let mut iv = [0u8; BLOCK]; + iv[..iv_size].copy_from_slice(&props[2 + salt_size..end]); + Some(Self { + ncp, + salt, + salt_len: salt_size, + iv, + }) + } +} + +/// Encodes a password (interpreted as a UTF-8 string) as UTF-16LE code units, the +/// form 7-Zip hashes. Lossy for invalid UTF-8, matching `String::from_utf8_lossy`. +fn password_utf16le(password: &[u8]) -> Vec { + let mut out = Vec::with_capacity(password.len() * 2); + for unit in String::from_utf8_lossy(password).encode_utf16() { + out.extend_from_slice(&unit.to_le_bytes()); + } + out +} + +/// The 7-Zip AES key schedule: a single SHA-256 context fed `salt || password || +/// counter` `2^ncp` times, where `counter` is a little-endian 64-bit round index. +/// `ncp == 0` degenerates to one round over `salt || password || 0u64`. +fn derive_key(ncp: u8, salt: &[u8], password_utf16le: &[u8]) -> [u8; 32] { + let mut sha = sha2::Sha256::default(); + let mut counter = [0u8; 8]; + for _ in 0..(1u64 << ncp) { + sha.update(salt); + sha.update(password_utf16le); + sha.update(counter); + for byte in &mut counter { + *byte = byte.wrapping_add(1); + if *byte != 0 { + break; + } + } + } + sha.finalize().into() +} + +/// Streaming AES-256-CBC decryption coder for a 7z folder. Holds one partial input +/// block and one partial output block (32 bytes of buffering total), so it never +/// retains more than a block regardless of the chunking below it. +pub(crate) struct AesDecoder { + dec: Aes256CbcDec, + in_block: [u8; BLOCK], + in_len: usize, + out_block: [u8; BLOCK], + out_pos: usize, + out_len: usize, + /// Remaining decrypted bytes to emit (the coder's declared output size). Trailing + /// ciphertext padding beyond this is dropped rather than surfaced. + remaining: u64, +} + +impl AesDecoder { + /// Builds a decoder from parsed properties and a raw (UTF-8) password. `out_size` + /// is the coder's declared uncompressed output size, used to truncate padding. + pub(crate) fn new(params: AesParams, out_size: u64, password: &[u8]) -> Self { + let mut pw = password_utf16le(password); + let mut key = derive_key(params.ncp, ¶ms.salt[..params.salt_len], &pw); + pw.zeroize(); + let dec = Aes256CbcDec::new(&Array::from(key), &Array::from(params.iv)); + key.zeroize(); + Self { + dec, + in_block: [0; BLOCK], + in_len: 0, + out_block: [0; BLOCK], + out_pos: 0, + out_len: 0, + remaining: out_size, + } + } +} + +impl Codec for AesDecoder { + fn process( + &mut self, + input: &[u8], + output: &mut [u8], + end: EndOfInput, + ) -> Result { + let mut consumed = 0usize; + let mut produced = 0usize; + loop { + if self.remaining == 0 { + // Output budget reached: drop any buffered plaintext and padding. + return Ok(CodecStep { + consumed, + produced, + status: CodecStatus::Done, + }); + } + // 1. Drain already-decrypted output first. + if self.out_pos < self.out_len { + let avail = self.out_len - self.out_pos; + let room = output.len() - produced; + let budget = usize::try_from(self.remaining).unwrap_or(usize::MAX); + let take = avail.min(room).min(budget); + if take == 0 { + break; // output is full + } + output[produced..produced + take] + .copy_from_slice(&self.out_block[self.out_pos..self.out_pos + take]); + self.out_pos += take; + produced += take; + self.remaining -= take as u64; + continue; + } + // 2. Fill the pending input block, decrypting it once complete. + while self.in_len < BLOCK && consumed < input.len() { + self.in_block[self.in_len] = input[consumed]; + self.in_len += 1; + consumed += 1; + } + if self.in_len == BLOCK { + let block: &mut Array = + self.in_block.as_mut_slice().try_into().map_err(|_| { + ArchiveError::new(ErrorKind::Malformed) + .with_context("7z AES: block framing") + })?; + self.dec.decrypt_block(block); + self.out_block = self.in_block; + self.out_pos = 0; + self.out_len = BLOCK; + self.in_len = 0; + continue; + } + break; // partial input block; need more bytes + } + if produced != 0 || consumed != 0 { + return Ok(CodecStep { + consumed, + produced, + status: CodecStatus::NeedInput, + }); + } + // No progress: a partial trailing block at end-of-input is padding and dropped. + let status = match end { + EndOfInput::End => CodecStatus::Done, + EndOfInput::More => CodecStatus::NeedInput, + }; + Ok(CodecStep { + consumed: 0, + produced: 0, + status, + }) + } +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::cast_possible_truncation, + clippy::trivially_copy_pass_by_ref +)] +mod tests { + use std::io::Read; + + use super::*; + use crate::codec_read::CodecReader; + + /// Builds coder properties matching what `sevenz-rust2` / 7-Zip write: + /// `b0 = ncp | 0xC0`, `b1 = 0xFF` (16-byte salt and IV), then salt, then IV. + fn props(ncp: u8, salt: &[u8; 16], iv: &[u8; 16]) -> Vec { + let mut p = vec![(ncp & 0x3F) | 0xC0, 0xFF]; + p.extend_from_slice(salt); + p.extend_from_slice(iv); + p + } + + /// Independent AES-256-CBC encryption of a padded plaintext, for a round trip. + fn encrypt(key: &[u8; 32], iv: &[u8; 16], plaintext: &[u8]) -> Vec { + use aes::cipher::BlockModeEncrypt; + type Enc = cbc::Encryptor; + let mut enc = Enc::new(&Array::from(*key), &Array::from(*iv)); + let mut padded = plaintext.to_vec(); + while !padded.len().is_multiple_of(16) { + padded.push(0); + } + for chunk in padded.chunks_mut(16) { + let block: &mut Array = chunk.try_into().unwrap(); + enc.encrypt_block(block); + } + padded + } + + #[test] + fn parses_standard_properties() { + let salt = [7u8; 16]; + let iv = [9u8; 16]; + let params = AesParams::parse(&props(8, &salt, &iv)).unwrap(); + assert_eq!(params.ncp, 8); + assert_eq!(params.salt_len, 16); + assert_eq!(¶ms.salt, &salt); + assert_eq!(¶ms.iv, &iv); + } + + #[test] + fn rejects_external_and_overlong_and_truncated() { + // External-key marker. + assert!(AesParams::parse(&[0x3F | 0xC0, 0xFF]).is_none()); + // Work factor above the DoS cap. + assert!(AesParams::parse(&props(30, &[0; 16], &[0; 16])).is_none()); + // Declared salt+IV longer than the property buffer. + assert!(AesParams::parse(&[0xC0, 0xFF, 1, 2, 3]).is_none()); + // Empty properties. + assert!(AesParams::parse(&[]).is_none()); + } + + #[test] + fn decrypts_round_trip_at_every_chunking() { + let salt = [0x11u8; 16]; + let iv = [0x22u8; 16]; + let ncp = 4; + let password = b"correct horse"; + let key = derive_key(ncp, &salt, &password_utf16le(password)); + let plaintext: Vec = (0..1000u32).map(|i| (i * 31 + 7) as u8).collect(); + let ciphertext = encrypt(&key, &iv, &plaintext); + + let params = AesParams::parse(&props(ncp, &salt, &iv)).unwrap(); + for chunk in [1usize, 3, 16, 17, 512, 4096] { + let reader = ChunkReader { + data: ciphertext.clone(), + pos: 0, + chunk, + }; + let decoder = AesDecoder::new(params, plaintext.len() as u64, password); + let mut cr = CodecReader::new(reader, decoder, "7z-aes"); + let mut out = Vec::new(); + cr.read_to_end(&mut out).unwrap(); + assert_eq!(out, plaintext, "mismatch at chunk {chunk}"); + } + } + + struct ChunkReader { + data: Vec, + pos: usize, + chunk: usize, + } + + impl Read for ChunkReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let n = (self.data.len() - self.pos).min(self.chunk).min(buf.len()); + buf[..n].copy_from_slice(&self.data[self.pos..self.pos + n]); + self.pos += n; + Ok(n) + } + } +} diff --git a/libarchive_oxide/src/filter/mod.rs b/libarchive_oxide/src/filter/mod.rs index 130f323..30b356b 100644 --- a/libarchive_oxide/src/filter/mod.rs +++ b/libarchive_oxide/src/filter/mod.rs @@ -4,6 +4,8 @@ //! Compression filter implementations and runtime dispatch. +#[cfg(all(feature = "sevenz", feature = "aes"))] +pub(crate) mod aes7z; #[cfg(feature = "sevenz")] pub(crate) mod bcj; #[cfg(feature = "sevenz")] diff --git a/libarchive_oxide/src/seek_stream.rs b/libarchive_oxide/src/seek_stream.rs index e777ef6..6454a76 100644 --- a/libarchive_oxide/src/seek_stream.rs +++ b/libarchive_oxide/src/seek_stream.rs @@ -316,7 +316,7 @@ impl SeekArchiveReader { { return Ok(Self { inner: SeekDispatch::SevenZ(Box::new(crate::sevenz::SevenZSeekReader::new( - input, limits, + input, limits, password, )?)), }); } diff --git a/libarchive_oxide/src/sevenz.rs b/libarchive_oxide/src/sevenz.rs index a3d0c25..4585c9f 100644 --- a/libarchive_oxide/src/sevenz.rs +++ b/libarchive_oxide/src/sevenz.rs @@ -7,12 +7,14 @@ //! Readers parse every folder's full coder graph (any number of coders, bind //! pairs, and packed streams) so listing works for any archive. Decoding is //! limited to linear chains over a single pack stream whose coders are each -//! LZMA2/LZMA, a decode-only byte filter (delta, BCJ), or a general-purpose -//! coder reused from the shared codec dispatch (Deflate, `BZip2`, Zstd): such a +//! LZMA2/LZMA, a decode-only byte filter (delta, BCJ), a general-purpose coder +//! reused from the shared codec dispatch (Deflate, `BZip2`, Zstd), or — under the +//! `aes` feature, given a password — the AES-256/SHA-256 decryption coder: such a //! folder streams normally, while richer graphs (BCJ2, `PPMd`, multi-pack) list //! but report `Unsupported` on extraction. Exactly one folder decode chain is live at a //! time, so memory stays bounded regardless of folder count. Writers still emit -//! a single solid LZMA2 folder. AES decode is not yet implemented. +//! a single solid LZMA2 folder. Each substream's stored CRC-32 is verified after decode, +//! which also distinguishes a correct 7z AES password from a wrong one. use std::io::{Read, Seek, SeekFrom, Take, Write}; @@ -26,7 +28,7 @@ use crate::codec_read::CodecReader; use crate::filter::bcj::{BcjDecoder, BranchKind}; use crate::filter::delta::DeltaDecoder; use crate::pipeline_codec::PipelineCodec; -use crate::{ReaderEvent, StreamError}; +use crate::{ReaderEvent, SecretBytes, StreamError}; type Result = core::result::Result; @@ -95,6 +97,8 @@ const METHOD_DEFLATE: [u8; 3] = [0x04, 0x01, 0x08]; const METHOD_BZIP2: [u8; 3] = [0x04, 0x02, 0x02]; /// Zstandard coder method id (4 bytes), as used by the 7-Zip-zstd fork and `sevenz-rust2`. const METHOD_ZSTD: [u8; 4] = [0x04, 0xF7, 0x11, 0x01]; +/// AES-256/SHA-256 encryption coder method id (4 bytes). +const METHOD_AES256: [u8; 4] = [0x06, 0xF1, 0x07, 0x01]; /// Peak decompression working set for a bzip2 stream at the largest (900 KiB) block size. /// libbzip2 documents ~3.7 MiB for `-9` decompression; rounded up. A folder's bzip2 coder is @@ -138,6 +142,10 @@ enum CoderMethod { Lzma { props: [u8; 5] }, /// A decode-only byte-to-byte filter (delta or BCJ) applied after its inner coder. Filter(SevenFilter), + /// AES-256/SHA-256 decryption, carrying its parsed (archive-public) salt/IV/work + /// factor. The secret key is derived only at decode time from the caller's password. + #[cfg(feature = "aes")] + Aes(crate::filter::aes7z::AesParams), /// A coder whose metadata can be listed but whose payload cannot be decoded. Unsupported, } @@ -254,6 +262,8 @@ struct FolderInfo { decode_order: Vec, /// Per-substream (per content file) uncompressed sizes, in file order. substream_sizes: Vec, + /// Per-substream stored CRC-32 (`None` where the archive defines none), in file order. + substream_crcs: Vec>, } impl FolderInfo { @@ -273,6 +283,21 @@ impl FolderInfo { .iter() .all(|coder| !matches!(coder.method, CoderMethod::Unsupported)) } + + /// Whether the folder chains an AES-256/SHA-256 coder (so decoding needs a password). + #[cfg(feature = "aes")] + fn is_encrypted(&self) -> bool { + self.graph + .coders + .iter() + .any(|coder| matches!(coder.method, CoderMethod::Aes(_))) + } + + #[cfg(not(feature = "aes"))] + #[allow(clippy::unused_self)] + fn is_encrypted(&self) -> bool { + false + } } /// A parsed directory-entry-like record: name, kind, permissions, mtime, and (for content files) @@ -291,6 +316,8 @@ struct FileRec { /// Byte offset of this file's substream within its own folder's decoded output. stream_offset: usize, size: usize, + /// Stored CRC-32 of this file's decoded substream, verified after decode (`None` if absent). + crc: Option, } /// Bounded parser for the 7z next-header metadata. @@ -407,14 +434,15 @@ impl HeaderParser { // a flat list of `(folder_index, size)` pairs, one per substream across every folder in // order, is drawn down as content files appear. The per-folder byte offset resets whenever // the folder changes. Empty-stream files are directories unless flagged as empty files. - let flat: Vec<(usize, u64)> = folders + let flat: Vec<(usize, u64, Option)> = folders .iter() .enumerate() .flat_map(|(index, folder)| { folder .substream_sizes .iter() - .map(move |&size| (index, size)) + .zip(folder.substream_crcs.iter()) + .map(move |(&size, &crc)| (index, size, crc)) }) .collect(); let mut content_index = 0usize; @@ -427,10 +455,10 @@ impl HeaderParser { let full_mode = modes.get(i).copied().flatten(); let has_stream = !empty_stream[i]; - let (kind, folder_index, offset, size, is_anti) = if has_stream { - let (fi, raw_size) = *flat.get(content_index).ok_or(HeaderError::Malformed( - "7z: content stream index out of range", - ))?; + let (kind, folder_index, offset, size, is_anti, crc) = if has_stream { + let (fi, raw_size, crc) = *flat.get(content_index).ok_or( + HeaderError::Malformed("7z: content stream index out of range"), + )?; let size = usize_of(raw_size)?; if current_folder != Some(fi) { running = 0; @@ -446,7 +474,7 @@ impl HeaderParser { } else { EntryKind::File }; - (kind, Some(fi), offset, size, false) + (kind, Some(fi), offset, size, false, crc) } else { let is_empty_file = empty_file.get(empty_index).copied().unwrap_or(false); let is_anti = anti_empty.get(empty_index).copied().unwrap_or(false); @@ -456,7 +484,7 @@ impl HeaderParser { } else { EntryKind::Dir }; - (kind, None, 0, 0, is_anti) + (kind, None, 0, 0, is_anti, None) }; let mode = permission_bits(full_mode, kind); @@ -476,6 +504,7 @@ impl HeaderParser { folder_index, stream_offset: offset, size, + crc, }); } Ok(()) @@ -501,6 +530,9 @@ enum SevenStage { /// A general-purpose decompression coder (Deflate/BZip2/Zstd) reused from [`PipelineCodec`], /// reading the stage below. Pipeline(Box, PipelineCodec>>), + /// An AES-256/SHA-256 decryption coder reading the stage below. + #[cfg(feature = "aes")] + Aes(Box, crate::filter::aes7z::AesDecoder>>), } enum SevenInput { @@ -518,6 +550,8 @@ impl SevenStage { Self::Delta(reader) => reader.get_ref().source_ref(), Self::Bcj(reader) => reader.get_ref().source_ref(), Self::Pipeline(reader) => reader.get_ref().source_ref(), + #[cfg(feature = "aes")] + Self::Aes(reader) => reader.get_ref().source_ref(), } } @@ -530,6 +564,8 @@ impl SevenStage { Self::Delta(reader) => reader.into_inner().into_source(), Self::Bcj(reader) => reader.into_inner().into_source(), Self::Pipeline(reader) => reader.into_inner().into_source(), + #[cfg(feature = "aes")] + Self::Aes(reader) => reader.into_inner().into_source(), } } } @@ -560,6 +596,8 @@ impl Read for SevenStage { Self::Delta(reader) => reader.read(output), Self::Bcj(reader) => reader.read(output), Self::Pipeline(reader) => reader.read(output), + #[cfg(feature = "aes")] + Self::Aes(reader) => reader.read(output), } } } @@ -581,6 +619,8 @@ enum SevenPhase { pub(crate) struct SevenZSeekReader { input: Option>, limits: Limits, + /// Zeroizing password for AES folders (`None` when the caller supplied none). Never logged. + password: Option, archive_metadata: Option, files: Vec, folders: Vec, @@ -592,6 +632,12 @@ pub(crate) struct SevenZSeekReader { /// Decoded byte position within the currently active folder (reset on every folder switch). decoded_position: usize, decoded_total: u64, + /// Running CRC-32 and the stored digest for the open content entry, present only when the + /// entry declares a CRC and a decoder is live. Verified when the entry's data is exhausted. + entry_crc: Option<(crate::filter::Crc32, u32)>, + /// Whether the active folder contains an AES coder, so a CRC mismatch can be reported as a + /// likely wrong password rather than generic corruption. + active_folder_encrypted: bool, } impl std::fmt::Debug for SevenZSeekReader { @@ -607,14 +653,20 @@ impl std::fmt::Debug for SevenZSeekReader { } impl SevenZSeekReader { - pub(crate) fn new(mut input: R, limits: Limits) -> core::result::Result { - let (archive_metadata, files, folders) = parse_seek_layout(&mut input, limits)?; + pub(crate) fn new( + mut input: R, + limits: Limits, + password: Option, + ) -> core::result::Result { + let (archive_metadata, files, folders) = + parse_seek_layout(&mut input, limits, password.as_ref())?; validate_seek_layout(&files, &folders, limits)?; // Folder decoders are built lazily, one at a time, when the first file of each folder is // reached. That keeps exactly one LZMA/LZMA2 workspace resident regardless of folder count. Ok(Self { input: Some(SevenInput::Source(input)), limits, + password, archive_metadata: Some(archive_metadata), files, folders, @@ -624,6 +676,8 @@ impl SevenZSeekReader { event_data: Vec::with_capacity(64 * 1024), decoded_position: 0, decoded_total: 0, + entry_crc: None, + active_folder_encrypted: false, }) } @@ -646,6 +700,8 @@ impl SevenZSeekReader { return Ok(ReaderEvent::Entry(metadata)); }, SevenPhase::Data { remaining: 0 } => { + // The whole substream has been delivered; the stored CRC-32 (if any) must match. + self.verify_entry_crc()?; self.phase = SevenPhase::EndEntry; }, SevenPhase::Data { remaining } => { @@ -659,6 +715,10 @@ impl SevenZSeekReader { )); } self.event_data.truncate(count); + // Fold the freshly decoded content into the running CRC (disjoint field borrows). + if let Some((crc, _)) = &mut self.entry_crc { + crc.update(&self.event_data[..count]); + } self.phase = SevenPhase::Data { remaining: remaining - count, }; @@ -692,8 +752,13 @@ impl SevenZSeekReader { "folder ended while skipping a substream", )); } + // Even a skipped substream is CRC-checked — the bytes are decoded anyway. + if let Some((crc, _)) = &mut self.entry_crc { + crc.update(&scratch[..count]); + } remaining -= count; } + self.verify_entry_crc()?; self.phase = SevenPhase::EndEntry; Ok(()) }, @@ -740,6 +805,15 @@ impl SevenZSeekReader { if record.has_stream && has_decoder { self.drain_to(record.stream_offset)?; } + // Arm per-substream CRC verification for this entry's decoded content (drain bytes above are + // deliberately excluded). Absent a stored digest, decode proceeds unverified. + self.entry_crc = if record.has_stream && has_decoder { + record + .crc + .map(|expected| (crate::filter::Crc32::new(), expected)) + } else { + None + }; let mut link_target = None; if record.has_stream && record.kind == EntryKind::Symlink && has_decoder { if self @@ -754,6 +828,10 @@ impl SevenZSeekReader { } let mut target = vec![0; record.size]; self.read_decoded_exact(&mut target)?; + if let Some((crc, _)) = &mut self.entry_crc { + crc.update(&target); + } + self.verify_entry_crc()?; link_target = Some(ArchivePath::from_encoded(target, PathEncoding::Utf8)); self.phase = SevenPhase::EndEntry; } else if record.has_stream { @@ -888,6 +966,24 @@ impl SevenZSeekReader { Ok(()) } + /// Consumes the running per-substream CRC-32 (if armed) and compares it to the stored digest. + /// A mismatch on an encrypted folder is reported as a likely wrong password; otherwise as + /// generic corruption. Never a no-op silently: a `None` accumulator simply means the archive + /// stored no digest to check against. + fn verify_entry_crc(&mut self) -> core::result::Result<(), StreamError> { + if let Some((crc, expected)) = self.entry_crc.take() { + if crc.finalize() != expected { + let context = if self.active_folder_encrypted { + "wrong password or corrupt data" + } else { + "substream CRC-32 mismatch" + }; + return Err(seven_error(ErrorKind::Integrity, context)); + } + } + Ok(()) + } + /// Verifies that the currently active folder decoded exactly to its declared size and /// produced nothing past it. A no-op when no decoder is live (no folder yet, or an /// unsupported coder). Called before switching folders and once the last file is consumed. @@ -940,11 +1036,21 @@ impl SevenZSeekReader { .get(folder_index) .ok_or_else(|| seven_error(ErrorKind::Protocol, "folder index out of range"))?; let decodable = folder.is_decodable(); + let encrypted = folder.is_encrypted(); let pack_offset = folder.pack_offset; let pack_size = folder.pack_sizes.first().copied(); + // Reject a missing password before reclaiming the source, so the raw `R` stays reachable + // (via `into_inner`/`source_ref`) after the error. The secret is never touched here. + if decodable && encrypted && self.password.is_none() { + return Err(seven_error( + ErrorKind::Unsupported, + "7z AES entry requires a password", + )); + } let mut source = self.take_source()?; self.decoded_position = 0; self.active_folder = Some(folder_index); + self.active_folder_encrypted = encrypted; if !decodable { self.input = Some(SevenInput::Source(source)); return Ok(false); @@ -964,7 +1070,7 @@ impl SevenZSeekReader { } let take = source.take(size); let folder = &self.folders[folder_index]; - let decoder = build_folder_reader(take, folder, self.limits)?; + let decoder = build_folder_reader(take, folder, self.limits, self.password.as_ref())?; self.input = Some(SevenInput::Decoder(Box::new(decoder))); Ok(true) } @@ -993,6 +1099,7 @@ impl SevenZSeekReader { fn parse_seek_layout( input: &mut (impl Read + Seek), limits: Limits, + password: Option<&SecretBytes>, ) -> core::result::Result<(ArchiveMetadata, Vec, Vec), StreamError> { #![allow(clippy::too_many_lines)] let image_length = input.seek(SeekFrom::End(0)).map_err(StreamError::io)?; @@ -1072,7 +1179,7 @@ fn parse_seek_layout( "encoded-header coder is unsupported", )); } - decode_seek_header(input, folder, limits, image_length)? + decode_seek_header(input, folder, limits, image_length, password)? }, _ => { return Err(seven_error( @@ -1107,6 +1214,7 @@ fn decode_seek_header( folder: &FolderInfo, limits: Limits, image_length: u64, + password: Option<&SecretBytes>, ) -> core::result::Result, StreamError> { if limits .metadata_bytes() @@ -1131,7 +1239,7 @@ fn decode_seek_header( u64::try_from(pack_size) .map_err(|_| seven_error(ErrorKind::Limit, "encoded-header pack size exceeds u64"))?, ); - let mut decoder = build_folder_reader(take, folder, limits)?; + let mut decoder = build_folder_reader(take, folder, limits, password)?; let length = usize::try_from(folder.unpack_size) .map_err(|_| seven_error(ErrorKind::Limit, "decoded header exceeds address space"))?; let mut output = vec![0; length]; @@ -1250,6 +1358,7 @@ fn build_folder_reader( source: Take, folder: &FolderInfo, limits: Limits, + password: Option<&SecretBytes>, ) -> core::result::Result, StreamError> { if !folder.is_decodable() { return Err(seven_error( @@ -1266,7 +1375,7 @@ fn build_folder_reader( .get(folder.graph.out_base(coder_index)) .copied() .ok_or_else(|| seven_error(ErrorKind::Malformed, "coder output size missing"))?; - stage = wrap_coder(stage, coder.method, out_size, limits)?; + stage = wrap_coder(stage, coder.method, out_size, limits, password)?; } Ok(stage) } @@ -1277,6 +1386,7 @@ fn wrap_coder( method: CoderMethod, unpack_size: u64, limits: Limits, + #[cfg_attr(not(feature = "aes"), allow(unused_variables))] password: Option<&SecretBytes>, ) -> core::result::Result, StreamError> { match method { CoderMethod::Lzma2 { dict_prop } => { @@ -1315,6 +1425,19 @@ fn wrap_coder( "7z-pipeline", )))) }, + #[cfg(feature = "aes")] + CoderMethod::Aes(params) => { + // A password is mandatory to decode an AES folder. Its absence is a typed + // capability error; the secret itself is never placed in any message. + let password = password.ok_or_else(|| { + seven_error(ErrorKind::Unsupported, "7z AES entry requires a password") + })?; + let decoder = + crate::filter::aes7z::AesDecoder::new(params, unpack_size, password.expose()); + Ok(SevenStage::Aes(Box::new(CodecReader::new( + inner, decoder, "7z-aes", + )))) + }, CoderMethod::Unsupported => Err(seven_error( ErrorKind::Unsupported, "payload coder is unsupported", @@ -1401,11 +1524,13 @@ fn parse_streams_info(r: &mut ByteReader<'_>) -> Result> { // One uncompressed size per output stream, flat across every folder in order. let mut output_sizes_all: Vec = Vec::new(); let mut num_folders = 0usize; - // Per folder: whether its UnpackInfo already defined a CRC. Mainstream 7-Zip and `sevenz-rust2` + // Per folder: its UnpackInfo-defined CRC (`None` when absent). Mainstream 7-Zip and `sevenz-rust2` // do NOT put the folder CRC here — they store it only in SubStreamsInfo — so a single-substream - // folder still carries a digest there. Tracking this drives the SubStreamsInfo digest count. - let mut folder_has_crc: Vec = Vec::new(); + // folder still carries a digest there. Tracking this drives the SubStreamsInfo digest count and + // supplies the substream CRC for the single-substream + folder-CRC case. + let mut folder_crcs: Vec> = Vec::new(); let mut substream_sizes: Option>> = None; + let mut substream_crcs: Option>>> = None; loop { match r.u8()? { @@ -1429,7 +1554,7 @@ fn parse_streams_info(r: &mut ByteReader<'_>) -> Result> { pack_sizes = Some(sizes); }, K_CRC => { - let _ = read_digests_defined(r, num_pack)?; + let _ = read_digests(r, num_pack)?; }, _ => { return Err(HeaderError::Unsupported( @@ -1470,12 +1595,12 @@ fn parse_streams_info(r: &mut ByteReader<'_>) -> Result> { for _ in 0..total_outputs { output_sizes_all.push(r.number()?); } - folder_has_crc = vec![false; num_folders]; + folder_crcs = vec![None; num_folders]; loop { match r.u8()? { K_END => break, K_CRC => { - folder_has_crc = read_digests_defined(r, num_folders)?; + folder_crcs = read_digests(r, num_folders)?; }, _ => { return Err(HeaderError::Unsupported( @@ -1488,7 +1613,9 @@ fn parse_streams_info(r: &mut ByteReader<'_>) -> Result> { K_SUBSTREAMS_INFO => { // SubStreamsInfo needs each folder's final-output (unpack) size. let folder_unpack = folder_unpack_sizes(&graphs, &output_sizes_all)?; - substream_sizes = Some(parse_substreams_info(r, &folder_unpack, &folder_has_crc)?); + let (sizes, crcs) = parse_substreams_info(r, &folder_unpack, &folder_crcs)?; + substream_sizes = Some(sizes); + substream_crcs = Some(crcs); }, _ => { return Err(HeaderError::Unsupported( @@ -1530,6 +1657,17 @@ fn parse_streams_info(r: &mut ByteReader<'_>) -> Result> { "7z: substream folder-count mismatch", )); } + // Absent SubStreamsInfo: every folder is single-substream, its CRC (if any) taken from the + // folder-level digest. + let substream_crcs = match substream_crcs { + Some(crcs) => crcs, + None => folder_crcs.iter().map(|&crc| vec![crc]).collect(), + }; + if substream_crcs.len() != num_folders { + return Err(HeaderError::Malformed( + "7z: substream crc folder-count mismatch", + )); + } let base = SIGNATURE_HEADER_SIZE .checked_add(usize_of(pack_pos)?) @@ -1565,6 +1703,7 @@ fn parse_streams_info(r: &mut ByteReader<'_>) -> Result> { output_sizes, decode_order, substream_sizes: substream_sizes[index].clone(), + substream_crcs: substream_crcs[index].clone(), }); } Ok(folders) @@ -1763,6 +1902,21 @@ fn classify_method(codec: &[u8], props: &[u8]) -> Result { { Ok(CoderMethod::Unsupported) } + } else if codec == METHOD_AES256 { + // AES-256/SHA-256 lists on every build but only decodes when the `aes` feature is + // compiled in and the caller supplies a password. Properties that name an external + // key or an out-of-range work factor parse as `Unsupported` (listable, not decodable). + #[cfg(feature = "aes")] + { + match crate::filter::aes7z::AesParams::parse(props) { + Some(params) => Ok(CoderMethod::Aes(params)), + None => Ok(CoderMethod::Unsupported), + } + } + #[cfg(not(feature = "aes"))] + { + Ok(CoderMethod::Unsupported) + } } else { Ok(CoderMethod::Unsupported) } @@ -1881,20 +2035,29 @@ fn visit_coder( Ok(()) } -/// Parses a `SubStreamsInfo` block covering every folder, returning per-folder substream sizes. +/// Per-folder substream sizes paired with per-folder substream CRC-32s (`None` where undefined), +/// both indexed `[folder][substream]`. +type SubStreamTables = (Vec>, Vec>>); + +/// Parses a `SubStreamsInfo` block covering every folder, returning per-folder substream sizes and +/// per-folder substream CRC-32s (`None` where undefined). /// -/// `folder_unpack[i]` is folder `i`'s uncompressed size and `folder_has_crc[i]` whether its -/// `UnpackInfo` already defined a CRC. The digest count follows the 7z rule -/// `numDigests = Σ_folders (numSubstreams(i) == 1 && folderCrcDefined(i) ? 0 : numSubstreams(i))`, -/// and single-substream folders omit their stored size (it equals the folder size). +/// `folder_unpack[i]` is folder `i`'s uncompressed size and `folder_crcs[i]` its `UnpackInfo`-level +/// CRC (if any). The digest count follows the 7z rule +/// `numDigests = Σ_folders (numSubstreams(i) == 1 && folderCrcDefined(i) ? 0 : numSubstreams(i))`; +/// a single-substream folder whose CRC is already defined at the folder level reuses that value and +/// contributes no digest here. Single-substream folders also omit their stored size (it equals the +/// folder size). +#[allow(clippy::too_many_lines)] fn parse_substreams_info( r: &mut ByteReader<'_>, folder_unpack: &[u64], - folder_has_crc: &[bool], -) -> Result>> { + folder_crcs: &[Option], +) -> Result { let num_folders = folder_unpack.len(); let mut counts: Vec = vec![1; num_folders]; let mut sizes: Option>> = None; + let mut crcs: Option>>> = None; loop { match r.u8()? { @@ -1944,10 +2107,28 @@ fn parse_substreams_info( .iter() .enumerate() .fold(0usize, |total, (index, &n)| { - let already = n == 1 && folder_has_crc.get(index).copied().unwrap_or(false); + let already = n == 1 && folder_crcs.get(index).copied().flatten().is_some(); total.saturating_add(if already { 0 } else { n }) }); - let _ = read_digests_defined(r, unknown)?; + let digests = read_digests(r, unknown)?; + // Distribute digests over substreams in folder order, reusing the folder-level CRC + // for a single-substream folder that carried one. + let mut per_folder = Vec::with_capacity(num_folders); + let mut next = 0usize; + for (index, &n) in counts.iter().enumerate() { + let folder_crc = folder_crcs.get(index).copied().flatten(); + if n == 1 && folder_crc.is_some() { + per_folder.push(vec![folder_crc]); + } else { + let mut folder = Vec::with_capacity(n); + for _ in 0..n { + folder.push(digests.get(next).copied().flatten()); + next += 1; + } + per_folder.push(folder); + } + } + crcs = Some(per_folder); }, _ => { return Err(HeaderError::Unsupported( @@ -1971,17 +2152,38 @@ fn parse_substreams_info( } per_folder }; + // Absent a SubStreamsInfo kCRC block, a substream's CRC is only known when its folder carried a + // single-substream folder-level digest. + let crcs = crcs.unwrap_or_else(|| { + counts + .iter() + .enumerate() + .map(|(index, &n)| { + let folder_crc = folder_crcs.get(index).copied().flatten(); + if n == 1 && folder_crc.is_some() { + vec![folder_crc] + } else { + vec![None; n] + } + }) + .collect() + }); for (index, folder_sizes) in sizes.iter().enumerate() { if folder_sizes.len() != counts[index] { return Err(HeaderError::Malformed("7z: substream count mismatch")); } + if crcs[index].len() != counts[index] { + return Err(HeaderError::Malformed("7z: substream crc count mismatch")); + } } - Ok(sizes) + Ok((sizes, crcs)) } /// Reads a `Digests` structure (`AllAreDefined` byte + optional bit vector + one CRC per defined -/// item), returning the per-item "defined" flags after bounds-checking the CRCs. -fn read_digests_defined(r: &mut ByteReader<'_>, count: usize) -> Result> { +/// item), returning the per-item CRC-32 where defined (`None` otherwise). Retaining the values lets +/// the reader verify each substream after decode — the check that distinguishes a correct 7z AES +/// password from a wrong one. +fn read_digests(r: &mut ByteReader<'_>, count: usize) -> Result>> { if count == 0 { return Ok(Vec::new()); } @@ -1996,12 +2198,11 @@ fn read_digests_defined(r: &mut ByteReader<'_>, count: usize) -> Result Vec { + let mut w = SevenWriter::new(Cursor::new(Vec::new())).unwrap(); + w.set_encrypt_header(encrypt_header); + let pw: Password = password.into(); + w.set_content_methods(vec![AesEncoderOptions::new(pw).into()]); + w.push_archive_entry(ArchiveEntry::new_file(name), Some(data)) + .unwrap(); + w.finish().unwrap().into_inner() + } + + /// Reads the whole (single-entry) archive's content through arca's seek reader with `password`. + fn read_aes(bytes: &[u8], password: &str) -> Result, StreamError> { + let mut reader = SeekArchiveReader::with_password( + Cursor::new(bytes.to_vec()), + SecretBytes::new(password.as_bytes().to_vec()), + )?; + let mut content = Vec::new(); + loop { + match reader.next_event()? { + ReaderEvent::Data(d) => content.extend_from_slice(d), + ReaderEvent::Done => return Ok(content), + _ => {}, + } + } + } + + /// The correct password (including a non-ASCII code point, to exercise the UTF-16LE encoding) + /// decrypts to the original plaintext, matching what `sevenz-rust2` reads back from its own file. + /// Uses the default *encrypted* next-header, so this also proves the AES-coded `kEncodedHeader` + /// path decodes with the caller's password. + #[test] + fn correct_password_decodes_encrypted_header_and_content() { + let data = mixed_payload(); + let password = "córrèct-horse-battery-\u{1F510}"; + let bytes = build_aes(password, "secret.bin", &data, true); + + // The independent producer must read its own archive with the same password. + let mut reference = + SevenReader::new(Cursor::new(bytes.clone()), Password::from(password)).unwrap(); + assert_eq!(reference.read_file("secret.bin").unwrap(), data); + + assert_eq!(read_aes(&bytes, password).unwrap(), data); + } + + /// A wrong password decrypts the content to garbage of the right length; the stored per-substream + /// CRC-32 mismatch is surfaced as a typed `Integrity` error rather than silently returning corrupt + /// bytes. A plain header keeps the failure at the content coder (the CRC path), not the header. + #[test] + fn wrong_password_trips_integrity() { + let data = b"top secret payload, not for prying eyes\n".repeat(16); + let bytes = build_aes("correct-horse", "s.bin", &data, false); + + // The fixture is sound: the correct password decodes it cleanly. + assert_eq!(read_aes(&bytes, "correct-horse").unwrap(), data); + + let error = read_aes(&bytes, "wrong-horse").expect_err("wrong password must fail"); + assert_eq!( + error.archive_error().map(ArchiveError::kind), + Some(ErrorKind::Integrity), + "wrong 7z AES password must be reported as an integrity failure" + ); + } + + /// Opening a plain-header AES archive without any password lists metadata but reports a typed + /// capability error (never a panic, never leaked secrets) when the encrypted content is reached. + #[test] + fn missing_password_is_unsupported() { + let data = b"needs a key to read\n".repeat(8); + let bytes = build_aes("pw", "s.bin", &data, false); + + let mut reader = SeekArchiveReader::new(Cursor::new(bytes.clone())).unwrap(); + let error = loop { + match reader.next_event() { + Ok(ReaderEvent::Done) => panic!("expected a typed error for the missing password"), + Ok(_) => {}, + Err(error) => break error, + } + }; + assert_eq!( + error.archive_error().map(ArchiveError::kind), + Some(ErrorKind::Unsupported), + "a missing 7z AES password must be a typed capability error" + ); + } +} From 05c86a3071c17289ed88520382643997f0d5e7b6 Mon Sep 17 00:00:00 2001 From: Yasunobu <42543015+P4suta@users.noreply.github.com> Date: Fri, 24 Jul 2026 02:07:36 +0900 Subject: [PATCH 6/8] docs(7z): docs, structured coder-graph fuzz, and deferred seams [RM-303] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close RM-303 with the accountability + fuzz layer over the 7z coder graph: - Support matrix: rewrite the 7z row to list the full coder-graph method set (LZMA/LZMA2, Delta, BCJ family, Deflate, BZip2, Zstandard; multi-folder and general graphs), AES-256 in its own encryption column, and PPMd/BCJ2 as structured Unsupported. Add PPMd and BCJ2 deficit rows to the deficit table. - ADR-0012: add tracked-deficit rows for PPMd (new decoder deferred) and BCJ2 (multi-stream decode deferred) as typed Unsupported with declared resolution paths; refresh the "entire ledger" note. - fuzz: add the structured read_7z_graph target. It synthesizes a random 7z StreamsInfo (coder count, complex flags, arities/props, bind pairs, packed indices, coder/substream sizes) from `arbitrary`, frames it under correct start/next-header CRCs so the graph parser is always reached, and asserts the RM-303 invariant: no panic, bounded work, and only typed Malformed/Unsupported/ Integrity/Limit errors — stressing cycles, stream overlaps, and truncation (recomputed under the header CRC). Body lives in the portable fuzz_lib and is wired into TARGETS/run_target, the libFuzzer shim, and fuzz/Cargo.toml. - Seed fuzz/corpus/read_7z_graph with six differential-scenario archives (arca solid, sevenz-rust2 multi-folder, delta+LZMA2, BCJ+LZMA2, LZMA, Deflate). Verified: cargo build -p libarchive_oxide --features sevenz / "sevenz aes" / --no-default-features --features sevenz; cargo build -p xtask; codec-policy (portable stays C/FFI-free); sevenz_differential (14/14, incl. aes); fuzz_replay (3/3 — 76960 mutants + arbitrary batch across 18 targets uphold the invariant). Building the cargo-fuzz bin needs nightly + sanitizer and is deferred to CI. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/adr/0012-codec-capability-contract.md | 10 +- docs/support-matrix.md | 16 +- fuzz/Cargo.toml | 7 + fuzz/corpus/read_7z_graph/arca_solid.7z | Bin 0 -> 222 bytes fuzz/corpus/read_7z_graph/bcj_lzma2.7z | Bin 0 -> 60122 bytes fuzz/corpus/read_7z_graph/deflate_folder.7z | Bin 0 -> 144 bytes fuzz/corpus/read_7z_graph/delta_lzma2.7z | Bin 0 -> 40114 bytes fuzz/corpus/read_7z_graph/lzma_folder.7z | Bin 0 -> 135 bytes fuzz/corpus/read_7z_graph/multi_folder.7z | Bin 0 -> 252 bytes fuzz/fuzz_lib/src/lib.rs | 398 +++++++++++++++++++- fuzz/fuzz_targets/read_7z_graph.rs | 14 + libarchive_oxide/tests/fuzz_replay.rs | 2 +- 12 files changed, 439 insertions(+), 8 deletions(-) create mode 100644 fuzz/corpus/read_7z_graph/arca_solid.7z create mode 100644 fuzz/corpus/read_7z_graph/bcj_lzma2.7z create mode 100644 fuzz/corpus/read_7z_graph/deflate_folder.7z create mode 100644 fuzz/corpus/read_7z_graph/delta_lzma2.7z create mode 100644 fuzz/corpus/read_7z_graph/lzma_folder.7z create mode 100644 fuzz/corpus/read_7z_graph/multi_folder.7z create mode 100644 fuzz/fuzz_targets/read_7z_graph.rs diff --git a/docs/adr/0012-codec-capability-contract.md b/docs/adr/0012-codec-capability-contract.md index 304fbf2..172db06 100644 --- a/docs/adr/0012-codec-capability-contract.md +++ b/docs/adr/0012-codec-capability-contract.md @@ -81,10 +81,14 @@ gap must not be mistaken for discharging the obligation to close it. |---|---|---|---|---| | Portable **streaming** zstd **encode** | ZIP member write method 93 is `native-codecs`-only; portable selection returns a structured `Unsupported` error | `ruzstd` ships only a one-shot whole-buffer encoder (`ruzstd::encoding::compress_to_vec`, used for outer-filter *frames* and `create --zstd`); it cannot emit a single ZIP member as a bounded stream without buffering the whole member, which would break the core bounded-memory guarantee. The `native-codecs` `compression-codecs` encoder is a true streaming encoder. | A streaming, single-stream, spec-robust pure-Rust zstd encoder — contributed upstream to `ruzstd` or provided as a dedicated crate the engine consumes. The engine core does not absorb the encoder. | RM-307 (this ADR) → follow-on codec initiative | | Deflate64 read and write | ZIP method 9 returns a structured `Unsupported` error and still enumerates | No pure-Rust Deflate64 encoder exists; decoders are scarce and the write direction has effectively no consumer demand. | Feasibility decision (own pure-Rust decoder for read-only, external decoder, or leave unsupported); no encoder is planned. | RM-306 feasibility ADR | +| 7z **PPMd** decode (method `03 04 01`) | A 7z folder coded with PPMd lists normally; extraction returns a structured `Unsupported` error and enumeration continues | 7z's PPMd7 (variant H) has no wired bounded-memory pure-Rust decoder, and the engine core will not absorb the model. This is a *new decoder deferred*, not a bent guarantee — the coder graph parses, the capability is typed. | Adopt or contribute a pure-Rust PPMd7 decoder consumed behind the codec-provider boundary; read-only, no encoder planned. | RM-303 → follow-on codec initiative | +| 7z **BCJ2** decode (method `03 03 01 1B`) | A 7z coder graph containing BCJ2 lists normally; extraction returns a structured `Unsupported` error and enumeration continues | BCJ2 is a four-input, multi-stream branch converter that does not fit the one-active-linear-folder decode model that keeps 7z decoding bounded. This is a *multi-stream decode deferred* — the graph resolver already validates its bind pairs; only the decode stage is absent. | Extend the folder decoder with a bounded four-stream BCJ2 junction stage; read-only, no encoder planned. | RM-303 → follow-on decoder slice | -Every other mainstream codec used by the engine (deflate, bzip2, xz/LZMA, lz4) -has complete pure-Rust read **and** write on the `portable-codecs` profile, so -these two are the entire ledger, not a pervasive condition. +Every mainstream compression codec used by the engine (deflate, bzip2, xz/LZMA, +lz4) has complete pure-Rust read **and** write on the `portable-codecs` profile. +The two 7z-only entries (PPMd, BCJ2) are read-only-deferred coders behind an +already-typed capability, not bent guarantees; this table is the entire ledger, +not a pervasive condition. ## Consequences diff --git a/docs/support-matrix.md b/docs/support-matrix.md index ce90285..a288d98 100644 --- a/docs/support-matrix.md +++ b/docs/support-matrix.md @@ -12,7 +12,7 @@ scheme, metadata field, or producer quirk is accepted. | cpio | sequential | binary little/big endian, odc, newc, crc | yes | none | known-size entry creation | | ar | sequential | GNU and BSD | yes | none | thin members are reported as external references and are never materialized automatically | | ZIP/ZIP64 | seek or streaming | see [ZIP compression methods](#zip-compression-methods) grid | see grid | optional WinZip AES-256 AE-2; ZipCrypto not enabled by default | descriptors, ZIP64, Unicode path/comment, and extended/NTFS/Info-ZIP-UX timestamp extras are interpreted as typed metadata; Info-ZIP Unix uid/gid are surfaced when recorded in the central directory (as arca writes them) and synthesized on write; unknown extras are preserved verbatim | -| 7z | seek | LZMA/LZMA2, encoded headers, solid single-folder archives | yes | none (AES unsupported) | optional `sevenz`; multiple folders and general coder graphs are unsupported | +| 7z | seek | LZMA, LZMA2, Delta, BCJ (x86/PPC/IA64/ARM/ARMT/SPARC/ARM64/RISC-V), Deflate, BZip2, Zstandard; multi-folder and general coder graphs; plain and encoded headers | yes | AES-256 (SHA-256 KDF) | optional `sevenz`; one active folder decoder (bounded); PPMd and BCJ2 are structured `Unsupported` — see [deficits](#codec-capability-deficits) | | ISO 9660 | seek | ISO 9660, Rock Ridge, Joliet | yes | none | UDF and continuation-area coverage are not complete | | CAB | seek | read-only (MSCF): Store and MSZIP folders | no (read-only) | none | QUANTUM/LZX folders and cross-cabinet spanning are structured `Unsupported`; the MSZIP window is carried across a folder's `CFDATA` blocks | | XAR | seek | read-only: stored and zlib (`x-gzip`) data | no (read-only) | none | zlib-XML TOC; `x-bzip2` and other data encodings are structured `Unsupported` | @@ -46,8 +46,16 @@ resting state. Deflate64 (method 9) is not yet implemented in either direction; boundary, landing in a follow-on slice) and write is a retired won't-do (no pure-Rust encoder exists). Traditional ZipCrypto is not enabled by default. -7z BCJ/Delta, Deflate, BZip2, Zstandard, PPMd, AES, multi-folder, and arbitrary -coder-graph coverage remain roadmap work. +7z now reads the general coder graph: multi-folder archives, chained BCJ/Delta +filters over LZMA/LZMA2, and Deflate/BZip2/Zstandard coders, plus AES-256/SHA-256 +encryption as its own [container column](#archive-containers). Decoding keeps one +active folder decoder at a time (bounded memory) and verifies each substream's +stored CRC-32. The two remaining coders — PPMd and BCJ2 — list normally but return +a structured `Unsupported` on extraction; they are tracked deficits with declared +resolution paths (see [Codec capability deficits](#codec-capability-deficits)). +Malformed coder graphs (cycles, stream overlaps, truncation) are fuzzed through the +structured `read_7z_graph` target, which asserts no panic, bounded work, and only +typed errors. ### Codec capability deficits @@ -63,6 +71,8 @@ read+write on portable. |---|---|---|---|---| | Portable **streaming** zstd encode | ZIP write method 93 on `portable-codecs` → structured `Unsupported`; `native-codecs` write works | `ruzstd` ships only a one-shot whole-buffer encoder (`ruzstd::encoding::compress_to_vec`, used for outer-filter frames and `create --zstd`). It cannot emit a single ZIP member as a bounded stream without buffering the whole member, which would break the core bounded-memory guarantee. The engine refuses the path rather than weaken the guarantee. | A streaming, single-stream pure-Rust zstd encoder — upstream to `ruzstd` or a dedicated crate the engine consumes. | RM-307 → follow-on codec initiative | | Deflate64 (method 9) | ZIP method 9 read/write → structured `Unsupported` (not yet implemented) | Read: a mature pure-Rust decoder (`deflate64`) exists but is not yet wired. Write: no pure-Rust encoder exists and demand is effectively nil (matches libarchive). | Read: adopt the external `deflate64` decoder behind the codec-provider boundary in a follow-on slice. Write: **won't-do**, retired per [ADR-0013](adr/0013-rar5-udf-deflate64-feasibility.md). | RM-306 / ADR-0013 | +| 7z **PPMd** (method `03 04 01`) | a folder whose coder is PPMd lists normally; reaching its payload returns a structured `Unsupported` | 7z uses the PPMd variant H (PPMd7) model; no bounded-memory pure-Rust decoder is wired, and the engine will not absorb one into its core. | Adopt or contribute a pure-Rust PPMd7 decoder behind the codec-provider boundary, consumed like the other coders; no encoder is planned (read-only, matching libarchive). | RM-303 → follow-on codec initiative | +| 7z **BCJ2** (method `03 03 01 1B`) | a folder whose coder graph contains BCJ2 lists normally; reaching its payload returns a structured `Unsupported` | BCJ2 is a four-input branch converter (main + two call/jump streams + a range-coder control stream); it is inherently multi-stream and does not fit the one-active-linear-folder decode model that keeps memory bounded. | Extend the folder decoder with a bounded BCJ2 four-stream junction stage (the graph parser already resolves the bind pairs); no encoder is planned. | RM-303 → follow-on decoder slice | CAB and XAR are implemented as read-only seek-native providers (see the archive containers table above). RAR5 and UDF read scope is resolved by diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 2c7a814..4506df8 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -70,6 +70,13 @@ test = false doc = false bench = false +[[bin]] +name = "read_7z_graph" +path = "fuzz_targets/read_7z_graph.rs" +test = false +doc = false +bench = false + [[bin]] name = "read_iso" path = "fuzz_targets/read_iso.rs" diff --git a/fuzz/corpus/read_7z_graph/arca_solid.7z b/fuzz/corpus/read_7z_graph/arca_solid.7z new file mode 100644 index 0000000000000000000000000000000000000000..7a9969350eb9da6951b84967ad2bf04ebff48a37 GIT binary patch literal 222 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�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

mQ~gr+BoO5~uY3|cD;U3CYDi;Bho6cUEj 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 literal 0 HcmV?d00001 diff --git a/fuzz/corpus/read_7z_graph/deflate_folder.7z b/fuzz/corpus/read_7z_graph/deflate_folder.7z new file mode 100644 index 0000000000000000000000000000000000000000..b66deccd03242bd3caa545e848f2924a2d7bfbd5 GIT binary patch literal 144 zcmXr7+Ou9=hJmF=(?Qmf0RqgSw7*ULDlbpp)1Eq>p&sY_J#~XTPM>ECWbK@4`Lk2= zg-Q6S6^|}nknojWY?-=6Lqb=gyDij|5vck9e`W?o7B&V(PD?IEp?wdM7}&WP85o&a k7&&;_IT$#&7#UX0X=Y$xWfbIPC}7ZINMguj$OGF700aXfzyJUM literal 0 HcmV?d00001 diff --git a/fuzz/corpus/read_7z_graph/delta_lzma2.7z b/fuzz/corpus/read_7z_graph/delta_lzma2.7z new file mode 100644 index 0000000000000000000000000000000000000000..879cd165102cc19597c6984373ca8a8ce93c99b5 GIT binary patch literal 40114 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%0A|$L(nIp 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�QdF8oi1~6F~xQ*xH`IM|HpYU0A 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 literal 0 HcmV?d00001 diff --git a/fuzz/corpus/read_7z_graph/multi_folder.7z b/fuzz/corpus/read_7z_graph/multi_folder.7z new file mode 100644 index 0000000000000000000000000000000000000000..28aa57b4e04f81f2b9026fbbb2d7a1c94e716919 GIT binary patch literal 252 zcmXr7+Ou9=hJnRNwLxbK0|aP5=^fgt{)`MFi8%!si3(}?IVq_{3I&OkIr)hxTnrCb zZ!_4%G8jsFy_>S0k)6x+_qk?q#mNW4ws`L`NG9-g;5ZA+ScuV?THm)=>- zqaL;|**1, +} + +/// One synthesized folder: its coders plus the bind-pair / packed-stream index pools the encoder +/// draws from (cycled to the arity the emitted coders imply). +#[derive(Debug, Arbitrary)] +struct GraphFolder { + coders: Vec, + bind_in: Vec, + bind_out: Vec, + packed: Vec, +} + +/// A full `StreamsInfo` skeleton. +#[derive(Debug, Arbitrary)] +struct GraphSpec { + pack_pos: u8, + pack_sizes: Vec, + folders: Vec, + unpack_sizes: Vec, + include_folder_crc: bool, + include_substreams: bool, + num_unpack_streams: Vec, + substream_sizes: Vec, + include_substream_crc: bool, + include_files_info: bool, + truncate: u16, + pack_data: Vec, +} + +/// Writes a 7z variable-length number (`WriteNumber`), mirroring the reader's `ReadNumber`. +#[allow(clippy::cast_possible_truncation)] +fn graph_number(out: &mut Vec, value: u64) { + let mut first = 0u8; + let mut mask = 0x80u8; + let mut i = 0u32; + while i < 8 { + if value < (1u64 << (7 * (i + 1))) { + first |= (value >> (8 * i)) as u8; + break; + } + first |= mask; + mask >>= 1; + i += 1; + } + out.push(first); + let mut v = value; + for _ in 0..i { + out.push(v as u8); + v >>= 8; + } +} + +impl GraphCoder { + /// Emits this coder and returns the `(num_in, num_out)` arca will parse from the emitted bytes. + fn emit(&self, out: &mut Vec) -> (usize, usize) { + let id = GRAPH_METHOD_IDS[self.method as usize % GRAPH_METHOD_IDS.len()]; + let id_size = id.len() as u8; // every candidate id is <= 4 bytes + let has_props = !self.props.is_empty(); + let mut flags = id_size & 0x0F; + if self.complex { + flags |= 0x10; + } + if has_props { + flags |= 0x20; + } + // Rarely set the "alternative methods" bit (~6%), which the reader must type as Unsupported. + if self.alt < 16 { + flags |= 0x80; + } + out.push(flags); + out.extend_from_slice(id); + let (num_in, num_out) = if self.complex { + let ni = (self.num_in as usize % GRAPH_MAX_ARITY) + 1; + let no = (self.num_out as usize % GRAPH_MAX_ARITY) + 1; + graph_number(out, ni as u64); + graph_number(out, no as u64); + (ni, no) + } else { + (1, 1) + }; + if has_props { + let props: Vec = self.props.iter().copied().take(GRAPH_MAX_PROPS).collect(); + graph_number(out, props.len() as u64); + out.extend_from_slice(&props); + } + (num_in, num_out) + } +} + +impl GraphSpec { + /// Serializes the spec into a framed 7z archive image. + #[allow(clippy::cast_possible_truncation)] + fn encode(&self) -> Vec { + let folders: Vec<&GraphFolder> = self.folders.iter().take(GRAPH_MAX_FOLDERS).collect(); + + let mut streams = Vec::new(); + // ── PackInfo ── + streams.push(G_K_PACK_INFO); + graph_number(&mut streams, u64::from(self.pack_pos)); + let pack_sizes: Vec = self + .pack_sizes + .iter() + .take(GRAPH_MAX_UNPACK) + .map(|&s| u64::from(s)) + .collect(); + graph_number(&mut streams, pack_sizes.len() as u64); + streams.push(G_K_SIZE); + for &size in &pack_sizes { + graph_number(&mut streams, size); + } + streams.push(G_K_END); + + // ── UnpackInfo ── + streams.push(G_K_UNPACK_INFO); + streams.push(G_K_FOLDER); + graph_number(&mut streams, folders.len() as u64); + streams.push(0x00); // folder definitions are inline (external == 0) + let mut total_outputs = 0usize; + for folder in &folders { + let coders: Vec<&GraphCoder> = folder.coders.iter().take(GRAPH_MAX_CODERS).collect(); + let num_coders = coders.len().max(1); + graph_number(&mut streams, num_coders as u64); + let mut total_in = 0usize; + let mut total_out = 0usize; + for i in 0..num_coders { + // At least one coder even if the pool is empty (an LZMA2 stand-in). + let (ni, no) = match coders.get(i) { + Some(coder) => coder.emit(&mut streams), + None => { + streams.push(0x01); // id_size 1, simple coder + streams.push(0x21); // LZMA2 + (1, 1) + }, + }; + total_in += ni; + total_out += no; + } + total_outputs += total_out; + // (numOut - 1) bind pairs, drawn from the folder's index pools (cycled), kept roughly + // in range so some resolve cleanly and some overlap / cycle. + let num_bind = total_out.saturating_sub(1); + for k in 0..num_bind { + let in_index = pool_index(&folder.bind_in, k, total_in); + let out_index = pool_index(&folder.bind_out, k, total_out); + graph_number(&mut streams, in_index as u64); + graph_number(&mut streams, out_index as u64); + } + // Packed inputs = total_in - num_bind; >1 are listed explicitly, 1 is implicit. + let num_packed = total_in.saturating_sub(num_bind); + if num_packed > 1 { + for k in 0..num_packed { + let idx = pool_index(&folder.packed, k, total_in.max(1)); + graph_number(&mut streams, idx as u64); + } + } + } + streams.push(G_K_CODERS_UNPACK_SIZE); + for i in 0..total_outputs { + let size = self + .unpack_sizes + .get(i % self.unpack_sizes.len().max(1)) + .map_or(0, |&s| u64::from(s)); + graph_number(&mut streams, size); + } + if self.include_folder_crc { + streams.push(G_K_CRC); + streams.push(0x00); // not all defined + let bytes = folders.len().div_ceil(8); + streams.extend(std::iter::repeat_n(0u8, bytes)); // none defined + } + streams.push(G_K_END); + + // ── SubStreamsInfo (optional) ── + if self.include_substreams && !folders.is_empty() { + streams.push(G_K_SUBSTREAMS_INFO); + let counts: Vec = (0..folders.len()) + .map(|f| { + self.num_unpack_streams + .get(f % self.num_unpack_streams.len().max(1)) + .map_or(1, |&n| usize::from(n) % GRAPH_MAX_SUBSTREAMS) + }) + .collect(); + streams.push(G_K_NUM_UNPACK_STREAM); + for &c in &counts { + graph_number(&mut streams, c as u64); + } + streams.push(G_K_SIZE); + let mut si = 0usize; + for &c in &counts { + for _ in 0..c.saturating_sub(1) { + let size = self + .substream_sizes + .get(si % self.substream_sizes.len().max(1)) + .map_or(0, |&s| u64::from(s)); + graph_number(&mut streams, size); + si += 1; + } + } + if self.include_substream_crc { + streams.push(G_K_CRC); + let total: usize = counts.iter().sum(); + streams.push(0x01); // all defined + for _ in 0..total { + // Deliberately (almost certainly) wrong CRCs: a reached decode must surface a + // typed `Integrity` error, never a silent wrong answer. + streams.extend_from_slice(&0xDEAD_BEEFu32.to_le_bytes()); + } + } + streams.push(G_K_END); + } + streams.push(G_K_END); // end StreamsInfo + + // ── Assemble the plain kHeader body ── + let mut next = vec![G_K_HEADER, G_K_MAIN_STREAMS_INFO]; + next.extend_from_slice(&streams); + // Optional minimal FilesInfo (empty names) so a consistent graph reaches the decode path. + if self.include_files_info && !folders.is_empty() { + next.push(G_K_FILES_INFO); + let num_files = folders.len(); + graph_number(&mut next, num_files as u64); + graph_number(&mut next, u64::from(G_K_NAME)); + let mut name_body = vec![0x00u8]; // names are inline (external == 0) + for _ in 0..num_files { + name_body.extend_from_slice(&[0x00, 0x00]); // empty UTF-16 name (terminator only) + } + graph_number(&mut next, name_body.len() as u64); + next.extend_from_slice(&name_body); + graph_number(&mut next, u64::from(G_K_END)); + } + next.push(G_K_END); // end kHeader + + // Truncation knob: cut the header short *before* computing its CRC, so the outer checksum + // gate still passes and the reader hits the truncation inside the graph parser. + if self.truncate != 0 && !next.is_empty() { + let keep = (self.truncate as usize) % next.len(); + next.truncate(keep); + } + + // ── Frame it: signature header + pack region + next header ── + let pack: Vec = self + .pack_data + .iter() + .copied() + .take(GRAPH_MAX_PACKDATA) + .collect(); + let mut file = vec![0u8; 32]; + file[..6].copy_from_slice(&GRAPH_SIGNATURE); + file[6] = 0; // format version major + file[7] = 4; // format version minor + file.extend_from_slice(&pack); + let header_offset = pack.len() as u64; + let header_size = next.len() as u64; + let header_crc = crc32(&next); + file.extend_from_slice(&next); + file[12..20].copy_from_slice(&header_offset.to_le_bytes()); + file[20..28].copy_from_slice(&header_size.to_le_bytes()); + file[28..32].copy_from_slice(&header_crc.to_le_bytes()); + let start_crc = crc32(&file[12..32]); + file[8..12].copy_from_slice(&start_crc.to_le_bytes()); + file + } +} + +/// Picks an index from a pool (cycled), kept within `[0, modulus)` so it lands near the real stream +/// range — some in-range (resolvable), some colliding (overlap/cycle the resolver must reject). +fn pool_index(pool: &[u8], k: usize, modulus: usize) -> usize { + let modulus = modulus.max(1); + match pool.get(k % pool.len().max(1)) { + Some(&raw) => usize::from(raw) % modulus, + None => k % modulus, + } +} + +/// Asserts a 7z read failure is a *typed* archive error of an expected kind — the RM-303 contract +/// that a hostile coder graph never panics, never leaks an untyped error, and never silently lies. +fn assert_graph_typed(error: &StreamError) { + match error.archive_error().map(ArchiveError::kind) { + Some( + ErrorKind::Malformed | ErrorKind::Unsupported | ErrorKind::Integrity | ErrorKind::Limit, + ) => {}, + other => panic!("7z coder-graph fuzz surfaced a non-typed / unexpected error: {other:?}"), + } +} + +/// Structured 7z coder-graph target: synthesize a `StreamsInfo`, frame it, and drive the reader. +pub fn read_7z_graph(data: &[u8]) { + let mut input = arbitrary::Unstructured::new(data); + let Ok(spec) = GraphSpec::arbitrary(&mut input) else { + return; + }; + let archive = spec.encode(); + let mut reader = match SeekArchiveReader::with_limits(Cursor::new(archive), fuzz_limits()) { + Ok(reader) => reader, + Err(error) => { + assert_graph_typed(&error); + return; + }, + }; + let mut events = 0usize; + let mut payload = 0u64; + loop { + match reader.next_event() { + Ok(ReaderEvent::Entry(metadata)) => { + let _ = metadata.path().as_bytes(); + events = events.saturating_add(1); + }, + Ok(ReaderEvent::Data(bytes)) => { + payload = payload.saturating_add(bytes.len() as u64); + }, + Ok(ReaderEvent::Done) => return, + Ok(ReaderEvent::ArchiveMetadata(_) | ReaderEvent::EndEntry) => {}, + Ok(_) => return, + Err(error) => { + assert_graph_typed(&error); + return; + }, + } + if events > MAX_ENTRIES || payload > MAX_TOTAL_BYTES { + return; + } + } +} + /// All portable fuzz targets. pub const TARGETS: &[&str] = &[ "read_tar", @@ -432,6 +826,7 @@ pub const TARGETS: &[&str] = &[ "read_ar", "read_zip", "read_7z", + "read_7z_graph", "read_iso", "roundtrip_tar", "roundtrip_cpio", @@ -454,6 +849,7 @@ pub fn run_target(name: &str, data: &[u8]) { "read_ar" => read_ar(data), "read_zip" => read_zip(data), "read_7z" => read_7z(data), + "read_7z_graph" => read_7z_graph(data), "read_iso" => read_iso(data), "roundtrip_tar" => roundtrip_tar(&entries_from_bytes(data)), "roundtrip_cpio" => roundtrip_cpio(&entries_from_bytes(data)), diff --git a/fuzz/fuzz_targets/read_7z_graph.rs b/fuzz/fuzz_targets/read_7z_graph.rs new file mode 100644 index 0000000..267511b --- /dev/null +++ b/fuzz/fuzz_targets/read_7z_graph.rs @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2026 libarchive_oxide contributors +// +// SPDX-License-Identifier: MIT OR Apache-2.0 + +#![no_main] + +//! Thin libFuzzer shim → the portable `libarchive_oxide_fuzz_cases::read_7z_graph` invariant, which +//! synthesizes a random 7z `StreamsInfo` coder graph and asserts the reader never panics, stays +//! bounded, and only ever returns a typed error. +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + libarchive_oxide_fuzz_cases::read_7z_graph(data); +}); diff --git a/libarchive_oxide/tests/fuzz_replay.rs b/libarchive_oxide/tests/fuzz_replay.rs index 23470b6..66a2884 100644 --- a/libarchive_oxide/tests/fuzz_replay.rs +++ b/libarchive_oxide/tests/fuzz_replay.rs @@ -154,7 +154,7 @@ fn arbitrary_seeds_uphold_invariants() { } } - assert_eq!(TARGETS.len(), 17, "all fuzz targets are wired"); + assert_eq!(TARGETS.len(), 18, "all fuzz targets are wired"); assert_eq!(runs, TARGETS.len() * LENGTHS.len() * STREAMS); } From 4ddfcf6239ffd592029e3100781fbc102adf29f9 Mon Sep 17 00:00:00 2001 From: Yasunobu <42543015+P4suta@users.noreply.github.com> Date: Fri, 24 Jul 2026 05:24:36 +0900 Subject: [PATCH 7/8] fix(7z): cover Deflate variant in async poll_process after DEV-124 rebase [RM-303] The DEV-124 async poll_process dispatch (merged as #70) matches PipelineCodec variants explicitly; the sevenz Deflate variant added on this branch left that match non-exhaustive once rebased. Add the sevenz-gated Deflate arm, delegating to the default poll_process (RawInflateDecoder is an in-process codec). Co-Authored-By: Claude Opus 4.8 (1M context) --- libarchive_oxide/src/pipeline_codec.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/libarchive_oxide/src/pipeline_codec.rs b/libarchive_oxide/src/pipeline_codec.rs index f91aebd..b1c7b41 100644 --- a/libarchive_oxide/src/pipeline_codec.rs +++ b/libarchive_oxide/src/pipeline_codec.rs @@ -172,6 +172,8 @@ impl PipelineCodec { Self::Gzip(codec) => codec.poll_process(input, output, end, waker), #[cfg(not(feature = "native-codecs"))] Self::Gzip(codec) => codec.poll_process(input, output, end, waker), + #[cfg(feature = "sevenz")] + Self::Deflate(codec) => codec.poll_process(input, output, end, waker), #[cfg(feature = "bzip2")] Self::Bzip2(codec) => codec.poll_process(input, output, end, waker), #[cfg(feature = "zstd")] From e4cc04f3fa8af492a865bef43490892945dc065c Mon Sep 17 00:00:00 2001 From: Yasunobu <42543015+P4suta@users.noreply.github.com> Date: Fri, 24 Jul 2026 06:00:45 +0900 Subject: [PATCH 8/8] feat(7z): zeroize AES key schedule and drop the password copy [RM-303] Two hardenings for the security-sensitive 7z AES path (CodeQL flagged the area): - Enable the `zeroize` feature on the `aes` crate so the expanded AES-256 key schedule held inside the CBC decryptor is wiped on drop. Previously the derived key was zeroized but the cipher's round keys lingered in freed memory for both the 7z CBC and ZIP AE-2 CTR paths. - Rewrite `password_utf16le` over `slice::utf8_chunks` instead of `String::from_utf8_lossy`, so a password with invalid UTF-8 is never copied into an intermediate owned `String` that escapes zeroization; `chunk.valid()` borrows from the caller's buffer. Behaviour is byte-identical (one U+FFFD per maximal invalid subsequence), locked by a new equivalence test against the lossy path. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 1 + libarchive_oxide/Cargo.toml | 6 +++-- libarchive_oxide/src/filter/aes7z.rs | 40 +++++++++++++++++++++++++--- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c198bcf..3ecc698 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,7 @@ dependencies = [ "cipher", "cpubits", "cpufeatures", + "zeroize", ] [[package]] diff --git a/libarchive_oxide/Cargo.toml b/libarchive_oxide/Cargo.toml index 845389a..a7ef1f8 100644 --- a/libarchive_oxide/Cargo.toml +++ b/libarchive_oxide/Cargo.toml @@ -83,8 +83,10 @@ flate2 = { version = "1.1.9", default-features = false, features = ["zlib"], opt xz-codec = { package = "xz2", version = "0.1.7", optional = true } # Shared by xz and 7z. lzma-rust2 = { version = "0.16.5", default-features = false, features = ["std", "encoder", "xz"], optional = true } -# WinZip AE-2 requires SHA-1. -aes = { version = "0.9", optional = true } +# WinZip AE-2 requires SHA-1. `zeroize` wipes the expanded AES key schedule on drop, +# so a folder/member key never lingers in freed memory after the decoder is dropped +# (defense in depth for both the 7z CBC and the ZIP AE-2 CTR paths). +aes = { version = "0.9", features = ["zeroize"], optional = true } # 7z AES-256/SHA-256 uses CBC (ZIP AE-2 uses CTR via `ctr`). cbc = { version = "0.2", optional = true } ctr = { version = "0.10", optional = true } diff --git a/libarchive_oxide/src/filter/aes7z.rs b/libarchive_oxide/src/filter/aes7z.rs index b457dd9..4caac09 100644 --- a/libarchive_oxide/src/filter/aes7z.rs +++ b/libarchive_oxide/src/filter/aes7z.rs @@ -94,11 +94,22 @@ impl AesParams { } /// Encodes a password (interpreted as a UTF-8 string) as UTF-16LE code units, the -/// form 7-Zip hashes. Lossy for invalid UTF-8, matching `String::from_utf8_lossy`. +/// form 7-Zip hashes. Lossy for invalid UTF-8 (one U+FFFD per maximal invalid +/// subsequence), matching `String::from_utf8_lossy`. +/// +/// Uses [`slice::utf8_chunks`] rather than `from_utf8_lossy` so the password is never +/// copied into an intermediate owned `String`: `chunk.valid()` borrows directly from +/// the caller's buffer, so no un-zeroized heap copy of password-derived bytes is left +/// behind on the invalid-UTF-8 path. The returned buffer is the caller's to zeroize. fn password_utf16le(password: &[u8]) -> Vec { - let mut out = Vec::with_capacity(password.len() * 2); - for unit in String::from_utf8_lossy(password).encode_utf16() { - out.extend_from_slice(&unit.to_le_bytes()); + let mut out = Vec::with_capacity(password.len().saturating_mul(2)); + for chunk in password.utf8_chunks() { + for unit in chunk.valid().encode_utf16() { + out.extend_from_slice(&unit.to_le_bytes()); + } + if !chunk.invalid().is_empty() { + out.extend_from_slice(&(char::REPLACEMENT_CHARACTER as u16).to_le_bytes()); + } } out } @@ -282,6 +293,27 @@ mod tests { assert_eq!(¶ms.iv, &iv); } + #[test] + fn password_utf16le_matches_lossy_reference() { + // The `utf8_chunks`-based encoder (which avoids an un-zeroized password copy) + // must be byte-identical to `String::from_utf8_lossy(..).encode_utf16()`, + // including one U+FFFD per maximal invalid subsequence. + let cases: [&[u8]; 5] = [ + b"correct horse", + &[0x66, 0x6f, 0xff, 0x6f], // valid, one invalid byte, valid + &[0xff, 0xfe, 0xfd], // all invalid + &[0xf0, 0x28, 0x8c, 0x28], // invalid lead + stray continuations + &[], + ]; + for pw in cases { + let reference: Vec = String::from_utf8_lossy(pw) + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect(); + assert_eq!(password_utf16le(pw), reference, "pw = {pw:02x?}"); + } + } + #[test] fn rejects_external_and_overlong_and_truncated() { // External-key marker.