diff --git a/README.md b/README.md index 1325a81..40edc4d 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,11 @@ if let Some(fi) = archive.files_info() { // Directories are reported as R7zError::Directory; zero-byte files return an empty Vec. let data = archive.extract_to_memory(0)?; println!("{} bytes", data.len()); + +// Stream a file directly to any writer. +let mut out = std::fs::File::create("/tmp/first-file.bin")?; +let written = archive.extract_to_writer(0, &mut out)?; +println!("{written} bytes written"); ``` ### Reading — extract all to disk safely @@ -58,7 +63,8 @@ println!("{} bytes", data.len()); archive.extract_all(Path::new("/tmp/output"))?; ``` -`extract_all` creates directories and zero-byte files correctly and rejects unsafe archive paths. +`extract_all` creates directories and zero-byte files correctly, rejects unsafe archive paths, +and streams decoded file data to disk instead of buffering full decoded folders in memory. ### Reading — encrypted archives @@ -144,6 +150,8 @@ let archive = Archive::from_bytes(raw.into())?; | `archive.streams_info()` | `Option<&StreamInfo>` | Raw stream/pack metadata | | `archive.extract_to_memory(index: usize)` | `Result, R7zError>` | Decompress file at `index` (0-based) | | `archive.extract_to_memory_with_password(index, password)` | `Result, R7zError>` | Decrypt/decompress file at `index` | +| `archive.extract_to_writer(index, writer)` | `Result` | Stream file at `index` into a writer | +| `archive.extract_to_writer_with_password(index, writer, password)` | `Result` | Stream encrypted file data into a writer | | `archive.extract_all(dest: &Path)` | `Result<(), R7zError>` | Extract all files; creates subdirectories as needed | | `archive.extract_all_with_password(dest, password)` | `Result<(), R7zError>` | Extract all files from an encrypted archive | @@ -284,7 +292,8 @@ Interop tests cover behavioral parity for p7zip-created and r7z-created LZMA, LZMA2, and BCJ+x86+LZMA2 archives. The parity target is matching archive listing/extraction behavior: file names, file contents, nested paths, directories, zero-byte files, and exposed metadata where r7z supports it. -r7z does not guarantee byte-identical output to p7zip. +r7z does not guarantee byte-identical archive output, matching compression ratios, +or matching compressed stream bytes. LZHAM and Fast LZMA2 variants from p7zip-zstd are not supported. diff --git a/src/aes.rs b/src/aes.rs index 799d6db..3a24c61 100644 --- a/src/aes.rs +++ b/src/aes.rs @@ -7,11 +7,11 @@ //! //! | Byte | Bits | Meaning | //! |------|--------|------------------------------------------------| -//! | 0 | [5:0] | NumCyclesPower (0–62, or 0x3F for raw key) | -//! | 0 | [6] | IV present flag | -//! | 0 | [7] | Salt present flag | -//! | 1* | [7:4] | Extra salt bytes (if salt flag set) | -//! | 1* | [3:0] | Extra IV bytes (if IV flag set) | +//! | 0 | \[5:0\] | NumCyclesPower (0–62, or 0x3F for raw key) | +//! | 0 | \[6\] | IV present flag | +//! | 0 | \[7\] | Salt present flag | +//! | 1* | \[7:4\] | Extra salt bytes (if salt flag set) | +//! | 1* | \[3:0\] | Extra IV bytes (if IV flag set) | //! | 2+ | | Salt bytes, then IV bytes | //! //! \* Byte 1 is only present if either the salt or IV flag is set. @@ -26,6 +26,9 @@ use sha2::{Digest, Sha256}; type Aes256CbcDec = cbc::Decryptor; +/// Bound p7zip's default AES KDF cost while rejecting maliciously huge values. +pub(crate) const MAX_AES_NUM_CYCLES_POWER: u8 = 24; + /// Parsed AES-256-SHA-256 properties from a 7z coder. #[derive(Debug)] pub(crate) struct AesProperties { @@ -82,7 +85,11 @@ impl AesProperties { /// /// The password is first encoded as UTF-16LE. Then for `2^num_cycles_power` /// iterations, we feed `salt || password_utf16le || counter_le_8bytes` into SHA-256. -pub(crate) fn derive_key(password: &str, salt: &[u8], num_cycles_power: u8) -> [u8; 32] { +pub(crate) fn derive_key( + password: &str, + salt: &[u8], + num_cycles_power: u8, +) -> Result<[u8; 32], R7zError> { // Special case: 0x3F means raw key = salt || password, zero-padded if num_cycles_power == 0x3F { let pwd_utf16: Vec = password @@ -93,7 +100,11 @@ pub(crate) fn derive_key(password: &str, salt: &[u8], num_cycles_power: u8) -> [ let total: Vec = salt.iter().chain(pwd_utf16.iter()).copied().collect(); let len = total.len().min(32); key[..len].copy_from_slice(&total[..len]); - return key; + return Ok(key); + } + + if num_cycles_power > MAX_AES_NUM_CYCLES_POWER { + return Err(R7zError::Decompression); } let pwd_utf16: Vec = password @@ -120,7 +131,7 @@ pub(crate) fn derive_key(password: &str, salt: &[u8], num_cycles_power: u8) -> [ let result = hasher.finalize(); let mut key = [0u8; 32]; key.copy_from_slice(&result); - key + Ok(key) } /// Decrypt `data` using AES-256-CBC with the given key and IV. @@ -185,7 +196,7 @@ mod tests { fn derive_key_known_value() { // With 0 cycles (2^0 = 1 iteration), no salt, we can verify manually. // SHA256(password_utf16le || 0x0000000000000000) - let key = derive_key("a", &[], 0); + let key = derive_key("a", &[], 0).unwrap(); // "a" in UTF-16LE = [0x61, 0x00] // One round: SHA256([0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) let mut hasher = Sha256::new(); @@ -194,6 +205,14 @@ mod tests { assert_eq!(key, expected); } + #[test] + fn derive_key_rejects_excessive_cycle_power() { + assert!(matches!( + derive_key("a", &[], MAX_AES_NUM_CYCLES_POWER + 1), + Err(R7zError::Decompression) + )); + } + #[test] fn aes_cbc_decrypt_roundtrip() { use aes::Aes256; diff --git a/src/archive.rs b/src/archive.rs index 13e0137..700abb7 100644 --- a/src/archive.rs +++ b/src/archive.rs @@ -4,7 +4,7 @@ use crate::{ }; use bytes::Bytes; use memmap2::Mmap; -use std::io::Read; +use std::io::{BufWriter, Read, Write}; use std::ops::Range; use std::path::{Component, Path, PathBuf}; @@ -307,6 +307,49 @@ impl Archive { file_index: usize, password: Option<&str>, ) -> Result, R7zError> { + let mut bytes = Vec::new(); + self.extract_to_writer_with_password(file_index, &mut bytes, password)?; + Ok(bytes) + } + + /// Extract a single file by index into a writer. + /// + /// This streams the decoded folder into `writer` instead of materializing + /// the whole folder in memory. The returned value is the number of file + /// bytes written. + /// + /// # Errors + /// + /// Returns the same archive, codec, and CRC errors as + /// [`extract_to_memory`](Self::extract_to_memory), plus [`R7zError::Io`] for + /// writer failures. + pub fn extract_to_writer( + &self, + file_index: usize, + writer: &mut W, + ) -> Result { + self.extract_to_writer_with_password(file_index, writer, None) + } + + /// Extract a single file by index into a writer, supplying a password for + /// encrypted archives. + /// + /// The decoder stream is drained after the target file has been written + /// whenever a folder CRC is present, so corruption later in the same solid + /// block is still detected. + /// + /// # Errors + /// + /// Returns [`R7zError::PasswordRequired`] if the file is encrypted and no + /// password is supplied, [`R7zError::Crc`] for digest mismatches, + /// [`R7zError::Decompression`] for codec failures, or [`R7zError::Io`] for + /// writer failures. + pub fn extract_to_writer_with_password( + &self, + file_index: usize, + writer: &mut W, + password: Option<&str>, + ) -> Result { if file_index >= self.num_files() { return Err(R7zError::Parse); } @@ -316,9 +359,88 @@ impl Archive { return Err(R7zError::Directory); } if fi.is_some_and(|f| f.is_empty_stream(file_index) && f.is_empty_file(file_index)) { - return Ok(Vec::new()); + return Ok(0); + } + + let location = self.extraction_location(file_index)?; + let packed = &self.data[location.packed_range.clone()]; + let mut reader = codec::folder_reader( + &location.folder, + packed, + location.folder_unpack_size, + password, + )?; + + let mut folder_hasher = location.folder_digest.map(|_| crc32fast::Hasher::new()); + let mut stream_hasher = location.substream_digest.map(|_| crc32fast::Hasher::new()); + let mut decoded_len = 0u64; + let mut remaining_skip = location.stream_start; + let mut remaining_take = location.stream_size; + let mut written = 0u64; + let mut buf = [0u8; 8192]; + + loop { + let n = reader.read(&mut buf).map_err(|_| R7zError::Decompression)?; + if n == 0 { + break; + } + + decoded_len = decoded_len.checked_add(n as u64).ok_or(R7zError::Parse)?; + + if let Some(hasher) = folder_hasher.as_mut() { + hasher.update(&buf[..n]); + } + + let mut offset = 0usize; + if remaining_skip > 0 { + let skip = remaining_skip.min(n); + remaining_skip -= skip; + offset += skip; + } + + if remaining_skip == 0 && remaining_take > 0 && offset < n { + let take = remaining_take.min(n - offset); + let bytes = &buf[offset..offset + take]; + writer.write_all(bytes)?; + if let Some(hasher) = stream_hasher.as_mut() { + hasher.update(bytes); + } + remaining_take -= take; + written = written.checked_add(take as u64).ok_or(R7zError::Parse)?; + } + + if remaining_skip == 0 && remaining_take == 0 && location.folder_digest.is_none() { + break; + } + } + + if remaining_skip > 0 || remaining_take > 0 { + return Err(R7zError::Decompression); + } + + if let Some(expected) = location.folder_digest { + let actual = folder_hasher.ok_or(R7zError::Parse)?.finalize(); + if actual != expected { + return Err(R7zError::Crc); + } + } + + if let Some(expected) = location.substream_digest { + let actual = stream_hasher.ok_or(R7zError::Parse)?.finalize(); + if actual != expected { + return Err(R7zError::Crc); + } + } + + if decoded_len < location.stream_end_u64()? { + return Err(R7zError::Decompression); } + Ok(written) + } + + fn extraction_location(&self, file_index: usize) -> Result { + let fi = self.header.files_info(); let streams = self.streams_info().ok_or(R7zError::Parse)?; let pack_info = streams.pack_info.as_ref().ok_or(R7zError::Parse)?; let unpack_info = streams.unpack_info.as_ref().ok_or(R7zError::Parse)?; @@ -336,7 +458,7 @@ impl Archive { ) .ok_or(R7zError::Parse)?; - // Decompress the folder + // Locate the packed bytes for the folder that contains this file stream. let folder = unpack_info.parse_folder(folder_idx)?; let prior_pack_sizes = pack_info .pack_size @@ -350,30 +472,29 @@ impl Archive { let pack_pos = usize::try_from(pack_info.pack_pos).map_err(|_| R7zError::Parse)?; let data_start = checked_add_usize(checked_add_usize(32, pack_pos)?, pack_offset)?; let packed_range = checked_range(self.data.len(), data_start, pack_size)?; - let packed = &self.data[packed_range]; let folder_unpack_size = folder_total_unpack_size(folder_idx, unpack_info, substream_info)?; - let decompressed = - codec::decompress_folder_with_password(&folder, packed, folder_unpack_size, password)?; - validate_digest( - unpack_info.digests.get(folder_idx).copied().flatten(), - &decompressed, - )?; - - // Slice the target stream out of the decompressed folder data let stream_start = stream_offset_in_folder(folder_idx, stream_in_folder, substream_info, unpack_info)?; let stream_size = stream_size_at(folder_idx, stream_in_folder, substream_info, unpack_info)?; - let stream_range = checked_range(decompressed.len(), stream_start, stream_size as u64)?; - let extracted = decompressed[stream_range].to_vec(); - - if let Some(si) = substream_info { + let folder_digest = unpack_info.digests.get(folder_idx).copied().flatten(); + let substream_digest = if let Some(si) = substream_info { let crc_idx = substream_global_index(folder_idx, stream_in_folder, si)?; - validate_digest(si.digests.get(crc_idx).copied().flatten(), &extracted)?; - } - - Ok(extracted) + si.digests.get(crc_idx).copied().flatten() + } else { + None + }; + + Ok(ExtractionLocation { + folder, + packed_range, + folder_unpack_size, + stream_start, + stream_size, + folder_digest, + substream_digest, + }) } /// Extract all files to a directory. @@ -422,11 +543,13 @@ impl Archive { } std::fs::File::create(&dest_path)?; } else { - let bytes = self.extract_to_memory_with_password(i, password)?; if let Some(parent) = dest_path.parent() { std::fs::create_dir_all(parent)?; } - std::fs::write(&dest_path, &bytes)?; + let file = std::fs::File::create(&dest_path)?; + let mut writer = BufWriter::new(file); + self.extract_to_writer_with_password(i, &mut writer, password)?; + writer.flush()?; } } Ok(()) @@ -435,6 +558,26 @@ impl Archive { // ── helpers ────────────────────────────────────────────────────────────────── +struct ExtractionLocation { + folder: crate::Folder, + packed_range: Range, + folder_unpack_size: u64, + stream_start: usize, + stream_size: usize, + folder_digest: Option, + substream_digest: Option, +} + +impl ExtractionLocation { + fn stream_end_u64(&self) -> Result { + let end = self + .stream_start + .checked_add(self.stream_size) + .ok_or(R7zError::Parse)?; + u64::try_from(end).map_err(|_| R7zError::Parse) + } +} + fn checked_add_usize(lhs: usize, rhs: usize) -> Result { lhs.checked_add(rhs).ok_or(R7zError::Parse) } @@ -449,14 +592,6 @@ fn checked_range(total_len: usize, start: usize, len: u64) -> Result, bytes: &[u8]) -> Result<(), R7zError> { - if expected.is_some_and(|crc| crc32fast::hash(bytes) != crc) { - Err(R7zError::Crc) - } else { - Ok(()) - } -} - fn safe_archive_path(dest: &Path, name: &str) -> Result, R7zError> { if name.is_empty() || has_windows_prefix(name) || has_parent_component(name) { return Err(R7zError::UnsafePath(name.to_string())); diff --git a/src/bcj.rs b/src/bcj.rs index 6f730f1..bfcdbcc 100644 --- a/src/bcj.rs +++ b/src/bcj.rs @@ -7,6 +7,8 @@ //! //! The algorithm matches p7zip / LZMA SDK `Bra86.c` exactly. +use std::io::{self, Read}; + /// Test whether the most-significant byte of a 4-byte displacement indicates /// a near address (0x00 or 0xFF after biased addition). #[inline] @@ -14,9 +16,6 @@ fn test86_msb(b: u8) -> bool { (b.wrapping_add(1)) & 0xFE == 0 } -/// Lookup: prevMask value → number of relevant high bits to check. -const MASK_TO_BIT_NUMBER: [u32; 8] = [0, 1, 2, 2, 3, 3, 3, 3]; - /// Apply the x86 BCJ filter in-place (matches LZMA SDK `Bra86.c`). /// /// * `encoding` — `true` to convert relative → absolute (encode / pre-compress), @@ -29,91 +28,158 @@ const MASK_TO_BIT_NUMBER: [u32; 8] = [0, 1, 2, 2, 3, 3, 3, 3]; /// (fewer than 5) are left untouched and should be prepended to the next call. pub fn bcj_x86_convert(data: &mut [u8], ip: u32, state: &mut u32, encoding: bool) -> usize { let size = data.len(); + let mut pos: usize = 0; + let mut mask: u32 = *state & 7; if size < 5 { return 0; } let limit = size - 4; let ip = ip.wrapping_add(5); // p7zip pre-adds 5 - let mut buf_pos: usize = 0; - let mut prev_pos_t: usize = usize::MAX; // (SizeT)0 - 1 - let mut prev_mask: u32 = *state & 0x7; loop { - if buf_pos >= limit { - break; + let start = pos; + while pos < limit && (data[pos] & 0xFE) != 0xE8 { + pos += 1; + } + + let distance = pos - start; + if pos >= limit { + *state = if distance > 2 { + 0 + } else { + mask >> u32::try_from(distance).unwrap_or(0) + }; + return pos; } - // Scan for E8 (CALL) or E9 (JMP) starting at buf_pos - let found = data[buf_pos..limit] - .iter() - .position(|&b| (b & 0xFE) == 0xE8); - buf_pos = match found { - Some(offset) => buf_pos + offset, - None => break, - }; - - // Distance since last candidate - let prev_pos_t_new = buf_pos.wrapping_sub(prev_pos_t); - prev_pos_t = prev_pos_t_new; - if prev_pos_t > 3 { - prev_mask = 0; + if distance > 2 { + mask = 0; } else { - prev_mask = (prev_mask << (prev_pos_t.wrapping_sub(1) as u32)) & 0x7; - if prev_mask != 0 { - let check_byte_idx = buf_pos + 4 - MASK_TO_BIT_NUMBER[prev_mask as usize] as usize; - if !test86_msb(data[check_byte_idx]) { - prev_pos_t = buf_pos; - prev_mask = ((prev_mask << 1) & 0x7) | 1; - buf_pos += 1; + mask >>= u32::try_from(distance).unwrap_or(0); + if mask != 0 { + let test_idx = pos + usize::try_from(mask >> 1).unwrap_or(0) + 1; + if mask > 4 || mask == 3 || test86_msb(data[test_idx]) { + mask = (mask >> 1) | 4; + pos += 1; continue; } } } - prev_pos_t = buf_pos; - if test86_msb(data[buf_pos + 4]) { - // Read 4-byte displacement (LE) from p[1..5] - let p = buf_pos; - let mut src = u32::from(data[p + 1]) - | (u32::from(data[p + 2]) << 8) - | (u32::from(data[p + 3]) << 16) - | (u32::from(data[p + 4]) << 24); + if test86_msb(data[pos + 4]) { + let p = pos; + let mut value = u32::from(data[p + 4]) << 24 + | u32::from(data[p + 3]) << 16 + | u32::from(data[p + 2]) << 8 + | u32::from(data[p + 1]); + let current = ip.wrapping_add(pos as u32); + pos += 5; - let dest; - loop { - let d = if encoding { - ip.wrapping_add(buf_pos as u32).wrapping_add(src) - } else { - src.wrapping_sub(ip.wrapping_add(buf_pos as u32)) - }; - if prev_mask == 0 { - dest = d; - break; - } - let index = MASK_TO_BIT_NUMBER[prev_mask as usize] * 8; - let b = (d >> (24 - index)) as u8; - if !test86_msb(b) { - dest = d; - break; + if encoding { + value = value.wrapping_add(current); + } else { + value = value.wrapping_sub(current); + } + + if mask != 0 { + let shift = (mask & 6) << 2; + if test86_msb((value >> shift) as u8) { + let adjust_mask = ((0x100u64 << shift) - 1) as u32; + value ^= adjust_mask; + if encoding { + value = value.wrapping_add(current); + } else { + value = value.wrapping_sub(current); + } } - src = d ^ ((1u32 << (32 - index)).wrapping_sub(1)); + mask = 0; } - // Write back: MSB byte becomes 0x00 or 0xFF - data[p + 4] = (!(((dest >> 24) & 1).wrapping_sub(1))) as u8; - data[p + 3] = (dest >> 16) as u8; - data[p + 2] = (dest >> 8) as u8; - data[p + 1] = dest as u8; - buf_pos += 5; + data[p + 1] = value as u8; + data[p + 2] = (value >> 8) as u8; + data[p + 3] = (value >> 16) as u8; + data[p + 4] = (0u8).wrapping_sub(((value >> 24) & 1) as u8); } else { - prev_mask = ((prev_mask << 1) & 0x7) | 1; - buf_pos += 1; + mask = (mask >> 1) | 4; + pos += 1; + } + } +} + +pub(crate) struct BcjX86Reader { + inner: R, + tail: Vec, + pending: Vec, + pending_pos: usize, + state: u32, + input_offset: u64, + eof: bool, +} + +impl BcjX86Reader { + pub(crate) fn new(inner: R) -> Self { + Self { + inner, + tail: Vec::with_capacity(4), + pending: Vec::new(), + pending_pos: 0, + state: 0, + input_offset: 0, + eof: false, } } - *state = prev_mask; - buf_pos + fn fill_pending(&mut self) -> io::Result<()> { + self.pending.clear(); + self.pending_pos = 0; + + while self.pending.is_empty() && !self.eof { + let mut chunk = [0u8; 8192]; + let n = self.inner.read(&mut chunk)?; + + if n == 0 { + self.eof = true; + self.pending.extend_from_slice(&self.tail); + self.tail.clear(); + break; + } + + let mut data = Vec::with_capacity(self.tail.len() + n); + data.extend_from_slice(&self.tail); + data.extend_from_slice(&chunk[..n]); + + let processed = + bcj_x86_convert(&mut data, self.input_offset as u32, &mut self.state, false); + self.pending.extend_from_slice(&data[..processed]); + self.tail.clear(); + self.tail.extend_from_slice(&data[processed..]); + self.input_offset = self.input_offset.wrapping_add(processed as u64); + } + Ok(()) + } +} + +impl Read for BcjX86Reader { + fn read(&mut self, out: &mut [u8]) -> io::Result { + if out.is_empty() { + return Ok(0); + } + + if self.pending_pos == self.pending.len() { + self.fill_pending()?; + } + + let available = &self.pending[self.pending_pos..]; + if available.is_empty() { + return Ok(0); + } + + let n = available.len().min(out.len()); + out[..n].copy_from_slice(&available[..n]); + self.pending_pos += n; + Ok(n) + } } /// Decode (post-decompress) x86 BCJ filter. @@ -255,4 +321,64 @@ mod tests { // The MSB byte (data[4]) gets special treatment assert_eq!(disp, 0x0105); } + + #[test] + fn streaming_decode_matches_batch_for_chunk_sizes() { + let mut original = vec![0x90u8; 4096]; + for &pos in &[3usize, 10, 63, 127, 512, 1021, 2048, 3070] { + original[pos] = if pos % 2 == 0 { 0xE8 } else { 0xE9 }; + original[pos + 1] = (pos * 5) as u8; + original[pos + 2] = ((pos * 5) >> 8) as u8; + original[pos + 3] = 0; + original[pos + 4] = 0; + } + + let mut encoded = original.clone(); + bcj_x86_encode(&mut encoded); + + let mut expected = encoded.clone(); + bcj_x86_decode(&mut expected); + + for chunk_size in [1usize, 2, 3, 4, 5, 7, 16, 64] { + let cursor = std::io::Cursor::new(encoded.clone()); + let mut reader = BcjX86Reader::new(cursor); + let mut actual = Vec::new(); + let mut buf = vec![0u8; chunk_size]; + loop { + let n = reader.read(&mut buf).unwrap(); + if n == 0 { + break; + } + actual.extend_from_slice(&buf[..n]); + } + assert_eq!(actual, expected, "chunk_size={chunk_size}"); + } + } + + #[test] + fn streaming_decode_handles_split_instruction() { + let mut original = vec![0x90u8; 32]; + original[4] = 0xE8; + original[5] = 0x40; + original[6] = 0x00; + original[7] = 0x00; + original[8] = 0x00; + + let mut encoded = original.clone(); + bcj_x86_encode(&mut encoded); + + let cursor = std::io::Cursor::new(encoded); + let mut reader = BcjX86Reader::new(cursor); + let mut actual = Vec::new(); + let mut buf = [0u8; 2]; + loop { + let n = reader.read(&mut buf).unwrap(); + if n == 0 { + break; + } + actual.extend_from_slice(&buf[..n]); + } + + assert_eq!(actual, original); + } } diff --git a/src/codec.rs b/src/codec.rs index 6032522..c5726f3 100644 --- a/src/codec.rs +++ b/src/codec.rs @@ -1,8 +1,14 @@ -use crate::R7zError; +use crate::{Folder, R7zError}; use lzma_rust2::{Lzma2Reader, Lzma2Writer, LzmaOptions, LzmaReader, LzmaWriter}; use smallvec::SmallVec; use std::io::{Cursor, Read, Write}; +// Cap how much encrypted data we buffer before AES-CBC decryption to avoid +// unbounded memory growth on malicious or unexpectedly large inputs. Encrypted +// pack streams larger than this fail to decode; if that becomes a compatibility +// issue, this should become configurable or derived from validated metadata. +const MAX_BUFFERED_AES_BYTES: usize = 256 * 1024 * 1024; + /// Compress `data` with LZMA, returning `(properties, compressed_stream)`. /// /// `properties` is the 5-byte LZMA properties block to store in `CoderInfo`. @@ -53,101 +59,6 @@ pub fn compress_lzma2(data: &[u8]) -> Result<(u8, Vec), R7zError> { Ok((0x1c, compressed)) } -/// Decompress `input` using the given codec, returning the decompressed bytes. -/// -/// * `codec_id` — codec identifier bytes from `CoderInfo` -/// * `properties` — optional codec properties from `CoderInfo` -/// * `input` — compressed data (not including any `LZMA_ALONE` header) -/// * `unpack_size`— expected output size (used to build LZMA header) -pub fn decompress( - codec_id: &[u8], - properties: Option<&[u8]>, - input: &[u8], - unpack_size: u64, -) -> Result, R7zError> { - if codec_id == CODEC_COPY { - return Ok(input.to_vec()); - } - - if codec_id == CODEC_LZMA { - return decompress_lzma(properties, input, unpack_size); - } - - if codec_id == CODEC_LZMA2 { - return decompress_lzma2(properties, input); - } - - if codec_id == CODEC_BCJ_X86 { - // BCJ is applied in-place as a post-processing step after a prior - // decompressor. When called standalone, clone the input and decode. - let mut buf = input.to_vec(); - crate::bcj::bcj_x86_decode(&mut buf); - return Ok(buf); - } - - if codec_id == CODEC_AES_256_SHA_256 { - // AES requires a password — when called through the simple decompress - // path without one, signal that a password is needed. - return Err(R7zError::PasswordRequired); - } - - Err(R7zError::UnsupportedCodec(codec_id.to_vec())) -} - -/// Decompress or decrypt a single coder, with optional password for AES. -fn decompress_coder( - coder: &crate::CoderInfo, - input: &[u8], - unpack_size: u64, - password: Option<&str>, -) -> Result, R7zError> { - if *coder.codec_id == *CODEC_AES_256_SHA_256 { - let pwd = password.ok_or(R7zError::PasswordRequired)?; - let props_bytes = coder.properties.as_deref().ok_or(R7zError::Decompression)?; - let props = crate::aes::AesProperties::parse(props_bytes)?; - let key = crate::aes::derive_key(pwd, &props.salt, props.num_cycles_power); - return crate::aes::decrypt_aes256_cbc(input, &key, &props.iv); - } - decompress( - &coder.codec_id, - coder.properties.as_deref(), - input, - unpack_size, - ) -} - -fn decompress_lzma( - properties: Option<&[u8]>, - input: &[u8], - unpack_size: u64, -) -> Result, R7zError> { - let props = properties.ok_or(R7zError::Decompression)?; - if props.len() != 5 { - return Err(R7zError::Decompression); - } - let props_byte = props[0]; - let dict_size = u32::from_le_bytes([props[1], props[2], props[3], props[4]]); - - let mut reader = - LzmaReader::new_with_props(Cursor::new(input), unpack_size, props_byte, dict_size, None) - .map_err(|_| R7zError::Decompression)?; - let mut output = Vec::with_capacity(usize::try_from(unpack_size).unwrap_or(0)); - reader - .read_to_end(&mut output) - .map_err(|_| R7zError::Decompression)?; - Ok(output) -} - -fn decompress_lzma2(properties: Option<&[u8]>, input: &[u8]) -> Result, R7zError> { - let dict_size = lzma2_dict_size(properties)?; - let mut reader = Lzma2Reader::new(Cursor::new(input), dict_size, None); - let mut output = Vec::new(); - reader - .read_to_end(&mut output) - .map_err(|_| R7zError::Decompression)?; - Ok(output) -} - /// Decode the LZMA2 dictionary size from the 7z properties byte. /// /// The 7z spec encodes: `dict_size = (2 | (p & 1)) << ((p >> 1) + 11)` for p < 40, @@ -170,10 +81,22 @@ fn lzma2_dict_size(props: Option<&[u8]>) -> Result { } } +#[cfg(test)] +fn decompress_lzma2(properties: Option<&[u8]>, input: &[u8]) -> Result, R7zError> { + let dict_size = lzma2_dict_size(properties)?; + let mut reader = Lzma2Reader::new(Cursor::new(input), dict_size, None); + let mut output = Vec::new(); + reader + .read_to_end(&mut output) + .map_err(|_| R7zError::Decompression)?; + Ok(output) +} + /// Decompress all folders in a Folder chain and return the concatenated output. /// -/// For simple single-coder folders this just calls `decompress` once. -/// BCJ+LZMA chaining (bind pairs) is resolved in order. +/// This is the compatibility wrapper around the internal folder reader: it +/// builds the reader chain for the folder, drains it, and returns the decoded +/// bytes. /// /// # Errors /// @@ -195,15 +118,27 @@ pub fn decompress_folder( /// was supplied, or [`R7zError::Decompression`] / [`R7zError::UnsupportedCodec`] /// for other failures. pub fn decompress_folder_with_password( - folder: &crate::Folder, + folder: &Folder, packed_data: &[u8], unpack_size: u64, password: Option<&str>, ) -> Result, R7zError> { - if folder.coders.len() == 1 { - let coder = &folder.coders[0]; - return decompress_coder(coder, packed_data, unpack_size, password); - } + let mut reader = folder_reader(folder, packed_data, unpack_size, password)?; + let mut data = Vec::with_capacity(usize::try_from(unpack_size).unwrap_or(0)); + reader + .read_to_end(&mut data) + .map_err(|_| R7zError::Decompression)?; + Ok(data) +} + +pub(crate) fn folder_reader<'a>( + folder: &Folder, + packed_data: &'a [u8], + unpack_size: u64, + password: Option<&str>, +) -> Result, R7zError> { + let order = coder_execution_order(folder)?; + let mut reader: Box = Box::new(Cursor::new(packed_data)); // Multi-coder chain: resolve bind-pair ordering so that each coder's // output feeds the next one's input. @@ -214,16 +149,77 @@ pub fn decompress_folder_with_password( // // We build a topological order by figuring out which coder receives the // packed stream (starts first) and following the bind pairs. - let order = coder_execution_order(folder)?; - - let mut data = packed_data.to_vec(); for (i, &coder_idx) in order.iter().enumerate() { let coder = &folder.coders[coder_idx]; // For chained coders we don't know intermediate sizes; use 0 to signal "unknown". let size = if i == order.len() - 1 { unpack_size } else { 0 }; - data = decompress_coder(coder, &data, size, password)?; + reader = coder_reader(coder, reader, size, password)?; + } + Ok(reader) +} + +fn coder_reader<'a>( + coder: &crate::CoderInfo, + mut input: Box, + unpack_size: u64, + password: Option<&str>, +) -> Result, R7zError> { + if *coder.codec_id == *CODEC_COPY { + return Ok(input); + } + + if *coder.codec_id == *CODEC_LZMA { + let props = coder.properties.as_deref().ok_or(R7zError::Decompression)?; + if props.len() != 5 { + return Err(R7zError::Decompression); + } + let props_byte = props[0]; + let dict_size = u32::from_le_bytes([props[1], props[2], props[3], props[4]]); + let reader = LzmaReader::new_with_props(input, unpack_size, props_byte, dict_size, None) + .map_err(|_| R7zError::Decompression)?; + return Ok(Box::new(reader)); + } + + if *coder.codec_id == *CODEC_LZMA2 { + let dict_size = lzma2_dict_size(coder.properties.as_deref())?; + return Ok(Box::new(Lzma2Reader::new(input, dict_size, None))); + } + + if *coder.codec_id == *CODEC_BCJ_X86 { + return Ok(Box::new(crate::bcj::BcjX86Reader::new(input))); + } + + if *coder.codec_id == *CODEC_AES_256_SHA_256 { + let pwd = password.ok_or(R7zError::PasswordRequired)?; + let props_bytes = coder.properties.as_deref().ok_or(R7zError::Decompression)?; + let props = crate::aes::AesProperties::parse(props_bytes)?; + let key = crate::aes::derive_key(pwd, &props.salt, props.num_cycles_power)?; + let mut encrypted = Vec::new(); + read_to_end_bounded(&mut input, &mut encrypted, MAX_BUFFERED_AES_BYTES)?; + let decrypted = crate::aes::decrypt_aes256_cbc(&encrypted, &key, &props.iv)?; + return Ok(Box::new(Cursor::new(decrypted))); + } + + Err(R7zError::UnsupportedCodec(coder.codec_id.to_vec())) +} + +fn read_to_end_bounded( + input: &mut dyn Read, + output: &mut Vec, + max_len: usize, +) -> Result<(), R7zError> { + let mut buf = [0u8; 8192]; + loop { + let n = input.read(&mut buf).map_err(|_| R7zError::Decompression)?; + if n == 0 { + return Ok(()); + } + let new_len = output.len().checked_add(n).ok_or(R7zError::Decompression)?; + if new_len > max_len { + return Err(R7zError::Decompression); + } + output.extend_from_slice(&buf[..n]); } - Ok(data) } /// Determine the order in which coders should be executed for decompression. diff --git a/src/error.rs b/src/error.rs index d0e42e0..21233a7 100644 --- a/src/error.rs +++ b/src/error.rs @@ -40,6 +40,6 @@ pub enum R7zError { UnsafePath(String), /// The requested entry is a directory or anti-item, not a regular file. - #[error("entry is a directory")] + #[error("entry is a directory or anti-item")] Directory, } diff --git a/src/files_info.rs b/src/files_info.rs index 59d6bbc..8a60ead 100644 --- a/src/files_info.rs +++ b/src/files_info.rs @@ -1,4 +1,4 @@ -use crate::{sevenzip_varuint64_decode, Property}; +use crate::{parsers::bitmap_is_set, sevenzip_varuint64_decode, Property}; use bytes::Bytes; use nom::{bytes::complete::take, IResult}; @@ -19,6 +19,8 @@ pub struct FilesInfo { pub empty_files: Bytes, /// Raw bitmap of anti-item flags (empty = all false). Bit `i` = entry `i` is an anti-item. pub anti_items: Bytes, + /// Mapping from file index to ordinal within the empty-stream bitmap payloads. + empty_stream_ordinals: Vec>, } impl FilesInfo { @@ -96,10 +98,7 @@ impl FilesInfo { } fn empty_stream_ordinal(&self, i: usize) -> Option { - if !self.is_empty_stream(i) { - return None; - } - Some((0..i).filter(|&idx| self.is_empty_stream(idx)).count()) + self.empty_stream_ordinals.get(i).copied().flatten() } /// Parse a `FilesInfo` block from the header stream. @@ -300,6 +299,8 @@ impl FilesInfo { } } + let empty_stream_ordinals = empty_stream_ordinals(&empty_streams, n); + Ok(( input, FilesInfo { @@ -310,15 +311,25 @@ impl FilesInfo { empty_streams, empty_files, anti_items, + empty_stream_ordinals, }, )) } } -fn bitmap_is_set(bitmap: &[u8], index: usize) -> bool { - bitmap - .get(index / 8) - .is_some_and(|b| (b >> (7 - (index % 8))) & 1 == 1) +fn empty_stream_ordinals(empty_streams: &[u8], num_files: usize) -> Vec> { + let mut next_empty = 0usize; + (0..num_files) + .map(|i| { + if bitmap_is_set(empty_streams, i) { + let ordinal = next_empty; + next_empty += 1; + Some(ordinal) + } else { + None + } + }) + .collect() } /// Walk a `FilesInfo` block without allocating. Returns `num_files`. @@ -423,6 +434,7 @@ mod tests { empty_streams: Bytes::from_static(&[0b1110_0000]), empty_files: Bytes::from_static(&[0b0100_0000]), anti_items: Bytes::from_static(&[0b0010_0000]), + empty_stream_ordinals: vec![Some(0), Some(1), Some(2), None], }; assert!(fi.is_directory(0)); diff --git a/tests/p7zip_extract_parity_test.rs b/tests/p7zip_extract_parity_test.rs new file mode 100644 index 0000000..2db928e --- /dev/null +++ b/tests/p7zip_extract_parity_test.rs @@ -0,0 +1,205 @@ +mod support; + +use std::path::{Path, PathBuf}; + +use support::{ + assert_trees_equal, extract_with_p7zip, extract_with_r7z, run_7z_checked, write_fixture_files, + write_fixture_tree, +}; + +#[test] +fn p7zip_created_archives_extract_with_r7z_byte_for_byte() { + let matrix = [ + ("lzma_solid", &["-m0=LZMA", "-ms=on", "-mmt=off"][..]), + ("lzma_non_solid", &["-m0=LZMA", "-ms=off", "-mmt=off"][..]), + ("lzma2_solid", &["-m0=LZMA2", "-ms=on", "-mmt=off"][..]), + ("lzma2_non_solid", &["-m0=LZMA2", "-ms=off", "-mmt=off"][..]), + ( + "bcj_lzma2_solid", + &["-mf=BCJ", "-m0=LZMA2", "-ms=on", "-mmt=off"][..], + ), + ( + "bcj_lzma2_non_solid", + &["-mf=BCJ", "-m0=LZMA2", "-ms=off", "-mmt=off"][..], + ), + ]; + + for (name, options) in matrix { + let tmp = tempfile::tempdir().unwrap(); + let input = tmp.path().join("input"); + write_fixture_tree(&input); + + let archive_path = tmp.path().join(format!("{name}.7z")); + let mut args = vec![ + "a".to_string(), + archive_path.to_string_lossy().into_owned(), + "input".to_string(), + ]; + args.extend(options.iter().map(|arg| (*arg).to_string())); + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + run_7z_checked(&args, tmp.path()); + + let p7zip_out = tmp.path().join("p7zip-out"); + let r7z_out = tmp.path().join("r7z-out"); + extract_with_p7zip(tmp.path(), &archive_path, &p7zip_out); + extract_with_r7z(&archive_path, &r7z_out); + assert_trees_equal(&p7zip_out, &r7z_out); + + assert_archive_file_apis_match_source(&archive_path, &input, "input"); + } +} + +#[test] +fn r7z_created_archives_extract_with_p7zip_byte_for_byte() { + let codecs = [ + ("lzma", r7z::Codec::Lzma), + ("lzma2", r7z::Codec::Lzma2), + ("bcj_lzma2", r7z::Codec::Lzma2Bcj), + ]; + + for (codec_name, codec) in codecs { + let tmp = tempfile::tempdir().unwrap(); + let expected = tmp.path().join("expected"); + let files = write_fixture_files(&expected); + + let archive_path = tmp.path().join(format!("builder_{codec_name}.7z")); + let mut builder = r7z::ArchiveBuilder::new().compression(codec); + for (path, data) in &files { + builder = builder.add_file(path.to_str().unwrap(), data); + } + std::fs::write(&archive_path, builder.build().unwrap()).unwrap(); + assert_r7z_and_p7zip_outputs_match(&archive_path, &expected); + + let archive_path = tmp.path().join(format!("writer_single_{codec_name}.7z")); + write_with_archive_writer(&archive_path, codec, &files, false); + assert_r7z_and_p7zip_outputs_match(&archive_path, &expected); + + let archive_path = tmp.path().join(format!("writer_multi_{codec_name}.7z")); + write_with_archive_writer(&archive_path, codec, &files, true); + assert_r7z_and_p7zip_outputs_match(&archive_path, &expected); + } +} + +#[test] +fn streaming_extract_reports_corrupt_lzma_and_lzma2_payloads() { + for codec in [r7z::Codec::Lzma, r7z::Codec::Lzma2] { + let mut bytes = r7z::ArchiveBuilder::new() + .compression(codec) + .add_file("payload.bin", &vec![0x55u8; 64 * 1024]) + .build() + .unwrap(); + corrupt_middle_of_pack_stream(&mut bytes); + + let archive = r7z::Archive::from_bytes(bytes.into()).unwrap(); + let mut out = Vec::new(); + let err = archive.extract_to_writer(0, &mut out).unwrap_err(); + assert!(matches!( + err, + r7z::R7zError::Crc | r7z::R7zError::Decompression + )); + } +} + +#[test] +fn streaming_extract_stops_after_target_when_folder_crc_is_absent() { + let mut bytes = r7z::ArchiveBuilder::new() + .compression(r7z::Codec::Lzma2) + .add_file("first.txt", b"first") + .add_file("second.bin", &vec![0xA5u8; 128 * 1024]) + .build() + .unwrap(); + let archive = r7z::Archive::from_bytes(bytes.clone().into()).unwrap(); + let folder_digest = archive + .streams_info() + .unwrap() + .unpack_info + .as_ref() + .unwrap() + .digests + .first() + .copied() + .flatten(); + assert!( + folder_digest.is_none(), + "fixture should not carry a folder CRC" + ); + corrupt_late_in_pack_stream(&mut bytes); + + let archive = r7z::Archive::from_bytes(bytes.into()).unwrap(); + let mut out = Vec::new(); + let written = archive.extract_to_writer(0, &mut out).unwrap(); + assert_eq!(written, 5); + assert_eq!(out, b"first"); +} + +fn assert_archive_file_apis_match_source(archive_path: &Path, source_root: &Path, prefix: &str) { + let archive = r7z::Archive::open(archive_path).unwrap(); + let fi = archive.files_info().unwrap(); + + for i in 0..archive.num_files() { + if fi.is_directory(i) || fi.is_anti(i) { + continue; + } + + let name = fi.name(i).unwrap(); + let relative = name + .strip_prefix(prefix) + .and_then(|name| name.strip_prefix('/')) + .unwrap_or(name.as_str()); + let expected = std::fs::read(source_root.join(relative)).unwrap(); + + assert_eq!(archive.extract_to_memory(i).unwrap(), expected, "{name}"); + + let mut out = Vec::new(); + let written = archive.extract_to_writer(i, &mut out).unwrap(); + assert_eq!(written, expected.len() as u64, "{name}"); + assert_eq!(out, expected, "{name}"); + } +} + +fn assert_r7z_and_p7zip_outputs_match(archive_path: &Path, expected: &Path) { + let tmp = tempfile::tempdir().unwrap(); + let p7zip_out = tmp.path().join("p7zip"); + let r7z_out = tmp.path().join("r7z"); + + extract_with_p7zip(tmp.path(), archive_path, &p7zip_out); + extract_with_r7z(archive_path, &r7z_out); + + assert_trees_equal(expected, &p7zip_out); + assert_trees_equal(expected, &r7z_out); +} + +fn write_with_archive_writer( + archive_path: &Path, + codec: r7z::Codec, + files: &[(PathBuf, Vec)], + multi_folder: bool, +) { + let file = std::fs::File::create(archive_path).unwrap(); + let mut writer = r7z::ArchiveWriter::new(file).unwrap().compression(codec); + for (idx, (path, data)) in files.iter().enumerate() { + if multi_folder && idx == files.len() / 2 { + writer.new_folder().unwrap(); + } + writer + .append(path.to_str().unwrap(), data.as_slice()) + .unwrap(); + } + writer.finish().unwrap(); +} + +fn corrupt_middle_of_pack_stream(bytes: &mut [u8]) { + let pack_len = next_header_offset(bytes); + let offset = 32 + usize::try_from(pack_len / 2).unwrap(); + bytes[offset] ^= 0x55; +} + +fn corrupt_late_in_pack_stream(bytes: &mut [u8]) { + let pack_len = next_header_offset(bytes); + let offset = 32 + usize::try_from(pack_len.saturating_sub(8)).unwrap(); + bytes[offset] ^= 0x55; +} + +fn next_header_offset(bytes: &[u8]) -> u64 { + u64::from_le_bytes(bytes[12..20].try_into().unwrap()) +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs index 19db5eb..c2f6363 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -1,5 +1,6 @@ #![allow(dead_code)] use std::{ + collections::BTreeSet, env, fs::{self, File}, io::Read, @@ -31,32 +32,47 @@ pub fn run_7z(args: &[&str], dir: &std::path::Path) -> std::process::Output { .expect("nix-shell not available; install p7zip or enter a nix shell with p7zip") } -pub fn write_fixture_tree(dir: &Path) -> Vec<(PathBuf, Vec)> { - let files = vec![ - ( - PathBuf::from("alpha.txt"), - b"alpha payload repeated repeated repeated".to_vec(), - ), - ( - PathBuf::from("nested/beta.bin"), - (0u8..=127).cycle().take(4096).collect(), - ), - ( - PathBuf::from("nested/deep/gamma.txt"), - b"gamma\nwith\nmultiple\nlines\n".to_vec(), - ), - ]; +pub fn run_7z_checked(args: &[&str], dir: &Path) -> std::process::Output { + let out = run_7z(args, dir); + assert!( + out.status.success(), + "7z failed in {} with args {:?}\nstdout:\n{}\nstderr:\n{}", + dir.display(), + args, + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + out +} + +pub fn write_fixture_tree(root: &Path) -> Vec<(PathBuf, Vec)> { + let files = fixture_files(); + fs::create_dir_all(root.join("nested dir/empty_dir")).unwrap(); + fs::create_dir_all(root.join("deep/path")).unwrap(); - for (name, data) in &files { - if let Some(parent) = name.parent() { - fs::create_dir_all(dir.join(parent)).unwrap(); + for (path, data) in &files { + let full = root.join(path); + if let Some(parent) = full.parent() { + fs::create_dir_all(parent).unwrap(); } - fs::write(dir.join(name), data).unwrap(); + fs::write(full, data).unwrap(); } files } +pub fn write_fixture_files(root: &Path) -> Vec<(PathBuf, Vec)> { + let files = fixture_files(); + for (path, data) in &files { + let full = root.join(path); + if let Some(parent) = full.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(full, data).unwrap(); + } + files +} + pub fn assert_extracted_files(root: &Path, expected: &[(PathBuf, Vec)]) { for (name, original) in expected { let path = root.join(name); @@ -100,6 +116,12 @@ pub fn extract_with_p7zip(dir: &Path, archive_path: &Path, out_dir: &Path) { ); } +pub fn extract_with_r7z(archive: &Path, out_dir: &Path) { + fs::create_dir_all(out_dir).unwrap(); + let archive = r7z::Archive::open(archive).unwrap(); + archive.extract_all(out_dir).unwrap(); +} + pub fn list_with_p7zip(dir: &Path, archive_path: &Path) -> String { let out = run_7z(&["l", archive_path.to_str().unwrap()], dir); assert!( @@ -111,6 +133,37 @@ pub fn list_with_p7zip(dir: &Path, archive_path: &Path) -> String { String::from_utf8_lossy(&out.stdout).into_owned() } +pub fn assert_trees_equal(expected: &Path, actual: &Path) { + let expected_entries = tree_entries(expected); + let actual_entries = tree_entries(actual); + assert_eq!(actual_entries, expected_entries, "tree entry mismatch"); + + for entry in expected_entries { + let expected_path = expected.join(&entry); + let actual_path = actual.join(&entry); + assert_eq!( + actual_path.is_dir(), + expected_path.is_dir(), + "directory type mismatch for {}", + entry.display() + ); + assert_eq!( + actual_path.is_file(), + expected_path.is_file(), + "file type mismatch for {}", + entry.display() + ); + if expected_path.is_file() { + assert_eq!( + fs::read(&actual_path).unwrap(), + fs::read(&expected_path).unwrap(), + "file content mismatch for {}", + entry.display() + ); + } + } +} + fn shell_quote(arg: &str) -> String { if arg .bytes() @@ -121,3 +174,43 @@ fn shell_quote(arg: &str) -> String { format!("'{}'", arg.replace('\'', "'\\''")) } } + +fn fixture_files() -> Vec<(PathBuf, Vec)> { + let mut binary = Vec::with_capacity(1024 * 1024); + for i in 0..1024 * 1024 { + binary.push(((i * 31 + i / 7) & 0xff) as u8); + } + + let mut code = vec![0x90u8; 16 * 1024]; + for &pos in &[8usize, 64, 255, 1024, 4096, 8191, 12000, 15000] { + code[pos] = if pos % 2 == 0 { 0xE8 } else { 0xE9 }; + code[pos + 1] = (pos * 3) as u8; + code[pos + 2] = ((pos * 3) >> 8) as u8; + code[pos + 3] = 0; + code[pos + 4] = 0; + } + + vec![ + (PathBuf::from("alpha.txt"), b"alpha text\n".to_vec()), + ( + PathBuf::from("nested dir/beta.txt"), + b"beta text with spaces in the path\n".to_vec(), + ), + ( + PathBuf::from("nested dir/unicode-\u{2603}.txt"), + "snowman payload\n".as_bytes().to_vec(), + ), + (PathBuf::from("deep/path/empty.txt"), Vec::new()), + (PathBuf::from("binary/payload.bin"), binary), + (PathBuf::from("bin/code.bin"), code), + ] +} + +fn tree_entries(root: &Path) -> BTreeSet { + walkdir::WalkDir::new(root) + .min_depth(1) + .into_iter() + .map(|entry| entry.unwrap()) + .map(|entry| entry.path().strip_prefix(root).unwrap().to_path_buf()) + .collect() +}