From 5b8c32f5385e8ef22ef970965d06a90ce414a6e5 Mon Sep 17 00:00:00 2001 From: ChanTsune <41658782+ChanTsune@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:33:08 +0900 Subject: [PATCH 1/3] :sparkles: Add HashedPassword for write-side KDF reuse --- lib/src/entry/options.rs | 112 ++++++++++++++++++++++++++++++++++----- lib/src/entry/write.rs | 34 +++++++++--- lib/src/hash.rs | 69 +++++++++++++++++++++++- 3 files changed, 192 insertions(+), 23 deletions(-) diff --git a/lib/src/entry/options.rs b/lib/src/entry/options.rs index 9a73aae47..d9e321347 100644 --- a/lib/src/entry/options.rs +++ b/lib/src/entry/options.rs @@ -2,10 +2,11 @@ use crate::{compress, error::UnknownValueError}; pub(crate) use private::*; -use std::str::FromStr; +use std::{fmt, io, str::FromStr}; mod private { use super::*; + use std::fmt; /// Compression options. #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] @@ -16,20 +17,49 @@ mod private { XZ(compress::xz::XZCompressionLevel), } + /// Password variant used in cipher options. + /// + /// Holds either a raw password that needs hashing, or an already-hashed key. + #[derive(Clone)] + pub(crate) enum CipherPassword { + Raw(Password), + Hashed(super::HashedPassword), + } + + impl fmt::Debug for CipherPassword { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Raw(_) => f.write_str("CipherPassword::Raw([REDACTED])"), + Self::Hashed(_) => f.write_str("CipherPassword::Hashed([REDACTED])"), + } + } + } + /// Cipher options. - #[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] + #[derive(Clone)] pub struct Cipher { - pub(crate) password: Password, + pub(crate) password: CipherPassword, pub(crate) hash_algorithm: HashAlgorithm, pub(crate) cipher_algorithm: CipherAlgorithm, pub(crate) mode: CipherMode, } + impl fmt::Debug for Cipher { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Cipher") + .field("password", &self.password) + .field("hash_algorithm", &self.hash_algorithm) + .field("cipher_algorithm", &self.cipher_algorithm) + .field("mode", &self.mode) + .finish() + } + } + impl Cipher { /// Creates a new [Cipher]. #[inline] pub(crate) const fn new( - password: Password, + password: CipherPassword, hash_algorithm: HashAlgorithm, cipher_algorithm: CipherAlgorithm, mode: CipherMode, @@ -79,7 +109,10 @@ mod private { #[inline] fn password(&self) -> Option<&[u8]> { - self.cipher().map(|it| it.password.as_bytes()) + self.cipher().and_then(|it| match &it.password { + CipherPassword::Raw(p) => Some(p.as_bytes()), + CipherPassword::Hashed(_) => None, + }) } } @@ -130,6 +163,48 @@ mod private { } } +/// A pre-hashed password for efficient encrypted archive creation. +/// +/// Derives the password hash once using the specified [`HashAlgorithm`], producing a +/// 32-byte key suitable for AES-256 and Camellia-256. The result can be reused across +/// any number of entries, avoiding redundant key derivation. +/// +/// # Examples +/// +/// ```rust +/// use libpna::{HashedPassword, HashAlgorithm}; +/// +/// let hp = HashedPassword::new(b"password", HashAlgorithm::argon2id()).unwrap(); +/// ``` +#[derive(Clone)] +pub struct HashedPassword { + pub(crate) key: password_hash::Output, + pub(crate) phsf: String, +} + +impl HashedPassword { + /// Derives a password hash once. Reusable for any number of entries. + /// Key length is 32 bytes (suitable for AES-256 and Camellia-256). + /// + /// # Errors + /// + /// Returns an error if password hashing fails (e.g., invalid algorithm parameters). + #[inline] + pub fn new(password: impl AsRef<[u8]>, hash_algorithm: HashAlgorithm) -> io::Result { + crate::hash::new_hashed_password(password.as_ref(), hash_algorithm) + } +} + +impl fmt::Debug for HashedPassword { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HashedPassword") + .field("phsf", &"[REDACTED]") + .field("key", &"[REDACTED]") + .finish() + } +} + /// Compression method. #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] #[repr(u8)] @@ -575,7 +650,7 @@ impl TryFrom for DataKind { /// When reading an archive, use [`ReadOptions`] to provide the password for decryption. /// The compression algorithm and cipher mode are stored in the archive metadata, so you /// only need to provide the password. -#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] +#[derive(Clone, Debug)] pub struct WriteOptions { compress: Compress, cipher: Option, @@ -631,14 +706,14 @@ impl WriteOptions { } /// Builder for [`WriteOptions`]. -#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] +#[derive(Clone, Debug)] pub struct WriteOptionsBuilder { compression: Compression, compression_level: CompressionLevel, encryption: Encryption, cipher_mode: CipherMode, hash_algorithm: HashAlgorithm, - password: Option>, + password: Option, } impl Default for WriteOptionsBuilder { @@ -663,7 +738,7 @@ impl From for WriteOptionsBuilder { encryption: value.encryption(), cipher_mode: value.cipher_mode(), hash_algorithm: value.hash_algorithm(), - password: value.password().map(|p| p.to_vec()), + password: value.cipher.map(|c| c.password), } } } @@ -732,7 +807,19 @@ impl WriteOptionsBuilder { /// ``` #[inline] pub fn password>(&mut self, password: Option) -> &mut Self { - self.password = password.map(|it| it.as_ref().to_vec()); + self.password = password.map(|it| CipherPassword::Raw(it.as_ref().into())); + self + } + + /// Sets a pre-hashed password for encryption. + /// + /// Use this instead of [`password()`](Self::password) when writing many entries with the + /// same password to avoid repeated key derivation. + /// + /// Calling this clears any previously set raw password, and vice versa. + #[inline] + pub fn hashed_password(&mut self, hashed: &HashedPassword) -> &mut Self { + self.password = Some(CipherPassword::Hashed(hashed.clone())); self } @@ -770,10 +857,7 @@ impl WriteOptionsBuilder { pub fn build(&self) -> WriteOptions { let cipher = if self.encryption != Encryption::No { Some(Cipher::new( - self.password - .as_deref() - .expect("Password was not provided.") - .into(), + self.password.clone().expect("Password was not provided."), self.hash_algorithm, match self.encryption { Encryption::Aes => CipherAlgorithm::Aes, diff --git a/lib/src/entry/write.rs b/lib/src/entry/write.rs index 2ea30fdac..cb227b59f 100644 --- a/lib/src/entry/write.rs +++ b/lib/src/entry/write.rs @@ -4,7 +4,7 @@ use crate::{ Cipher, CipherAlgorithm, HashAlgorithm, cipher::{CipherWriter, Ctr128BEWriter, EncryptCbcAes256Writer, EncryptCbcCamellia256Writer}, compress::CompressionWriter, - entry::{CipherMode, Compress, HashAlgorithmParams, WriteOption}, + entry::{CipherMode, CipherPassword, Compress, HashAlgorithmParams, WriteOption}, hash, random, }; use aes::Aes256; @@ -35,13 +35,31 @@ pub(crate) struct EntryWriterContext { #[inline] fn to_hashed(cipher: &Cipher) -> io::Result { - let salt = random::salt_string(); - let (key, phsf) = hash( - cipher.cipher_algorithm, - cipher.hash_algorithm, - cipher.password.as_bytes(), - &salt, - )?; + let (key, phsf) = match &cipher.password { + CipherPassword::Hashed(h) => { + let expected = key_size(cipher.cipher_algorithm); + if h.key.len() != expected { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "Hashed password key size {} does not match cipher requirement {}", + h.key.len(), + expected, + ), + )); + } + (h.key, h.phsf.clone()) + } + CipherPassword::Raw(password) => { + let salt = random::salt_string(); + hash( + cipher.cipher_algorithm, + cipher.hash_algorithm, + password.as_bytes(), + &salt, + )? + } + }; let iv = match cipher.cipher_algorithm { CipherAlgorithm::Aes => random::random_vec(Aes256::block_size()), CipherAlgorithm::Camellia => random::random_vec(Camellia256::block_size()), diff --git a/lib/src/hash.rs b/lib/src/hash.rs index 089565625..e1a8b1e71 100644 --- a/lib/src/hash.rs +++ b/lib/src/hash.rs @@ -42,6 +42,49 @@ pub(crate) fn pbkdf2_with_salt<'a>( .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) } +/// Key size for HashedPassword: 32 bytes (AES-256 / Camellia-256). +const HASHED_PASSWORD_KEY_SIZE: usize = 32; + +pub(crate) fn new_hashed_password( + password: &[u8], + hash_algorithm: crate::HashAlgorithm, +) -> io::Result { + use crate::entry::HashAlgorithmParams; + + let salt = crate::random::salt_string(); + let mut password_hash = match hash_algorithm.0 { + HashAlgorithmParams::Argon2Id { + time_cost, + memory_cost, + parallelism_cost, + } => argon2_with_salt( + password, + argon2::Algorithm::Argon2id, + time_cost, + memory_cost, + parallelism_cost, + HASHED_PASSWORD_KEY_SIZE, + &salt, + ), + HashAlgorithmParams::Pbkdf2Sha256 { rounds } => { + let mut params = pbkdf2::Params { + output_length: HASHED_PASSWORD_KEY_SIZE, + ..Default::default() + }; + if let Some(rounds) = rounds { + params.rounds = rounds; + } + pbkdf2_with_salt(password, pbkdf2::Algorithm::Pbkdf2Sha256, params, &salt) + } + }?; + let key = password_hash + .hash + .take() + .ok_or_else(|| io::Error::new(io::ErrorKind::Unsupported, "Failed to get hash"))?; + let phsf = password_hash.to_string(); + Ok(crate::HashedPassword { key, phsf }) +} + pub(crate) fn derive_password_hash<'a>( phsf: &'a str, password: &'a [u8], @@ -87,7 +130,7 @@ pub(crate) fn derive_password_hash<'a>( #[cfg(test)] mod tests { use super::*; - use crate::random; + use crate::{HashAlgorithm, HashedPassword, random}; #[cfg(all(target_family = "wasm", target_os = "unknown"))] use wasm_bindgen_test::wasm_bindgen_test as test; @@ -127,4 +170,28 @@ mod tests { let ph = derive_password_hash(&ps, b"pass").unwrap(); assert!(ph.hash.is_some()); } + + #[test] + fn hashed_password_argon2() { + let hp = HashedPassword::new(b"password", HashAlgorithm::argon2id()).unwrap(); + assert_eq!(hp.key.len(), 32); + assert!(hp.phsf.starts_with("$argon2id$")); + } + + #[test] + fn hashed_password_pbkdf2() { + let hp = HashedPassword::new(b"password", HashAlgorithm::pbkdf2_sha256()).unwrap(); + assert_eq!(hp.key.len(), 32); + assert!(hp.phsf.starts_with("$pbkdf2-sha256$")); + } + + #[test] + fn hashed_password_debug_redacts_key() { + let hp = HashedPassword::new(b"password", HashAlgorithm::argon2id()).unwrap(); + let debug = format!("{hp:?}"); + assert!(debug.contains("[REDACTED]")); + assert!(!debug.contains("password")); + // PHSF must also be redacted — should not contain algorithm identifier + assert!(!debug.contains("$argon2id$")); + } } From 852a8cf90cf7beeabc5628eacaf344fb0d019831 Mon Sep 17 00:00:00 2001 From: ChanTsune <41658782+ChanTsune@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:34:25 +0900 Subject: [PATCH 2/3] :sparkles: Integrate KeyCache into read-side APIs --- cli/src/command/core.rs | 22 ++- cli/src/command/core/archive_source.rs | 5 +- cli/src/command/diff.rs | 7 +- cli/src/command/extract.rs | 13 +- cli/src/command/list.rs | 13 +- cli/tests/cli/stdio/option_ignore_zeros.rs | 23 ++-- cli/tests/cli/update/no_timestamp_archive.rs | 2 +- .../update/option_archive_missing_mtime.rs | 4 +- cli/tests/cli/utils/archive.rs | 8 +- fuzz/fuzz_targets/aes_cbc.rs | 4 +- fuzz/fuzz_targets/aes_ctr.rs | 4 +- fuzz/fuzz_targets/camellia_cbc.rs | 4 +- fuzz/fuzz_targets/camellia_ctr.rs | 4 +- fuzz/fuzz_targets/split_archive.rs | 10 +- lib/README.md | 2 +- lib/benches/create_extract.rs | 7 +- lib/examples/async_io.rs | 7 +- lib/examples/change_compression_method.rs | 2 +- lib/src/archive.rs | 19 ++- lib/src/archive/read.rs | 28 ++-- lib/src/archive/read/slice.rs | 17 ++- lib/src/archive/write.rs | 30 ++-- lib/src/entry.rs | 25 ++-- lib/src/entry/builder.rs | 10 +- lib/src/entry/key_cache.rs | 128 ++++++++++++++++++ lib/src/entry/options.rs | 91 ++++++++++--- lib/src/entry/read.rs | 28 ++-- lib/src/lib.rs | 2 +- lib/tests/extract_compatibility.rs | 3 +- lib/tests/extract_multipart_compatibility.rs | 3 +- lib/tests/extract_solid_compatibility.rs | 9 +- pna/README.md | 2 +- 32 files changed, 388 insertions(+), 148 deletions(-) create mode 100644 lib/src/entry/key_cache.rs diff --git a/cli/src/command/core.rs b/cli/src/command/core.rs index c3a94a3bd..f98ddd246 100644 --- a/cli/src/command/core.rs +++ b/cli/src/command/core.rs @@ -28,7 +28,7 @@ use path_slash::*; pub(crate) use path_transformer::PathTransformers; use pna::{ Archive, EntryBuilder, EntryPart, LinkTargetType, MIN_CHUNK_BYTES_SIZE, NormalEntry, - PNA_HEADER, ReadEntry, SolidEntryBuilder, WriteOptions, prelude::*, + PNA_HEADER, ReadEntry, ReadOptions, SolidEntryBuilder, WriteOptions, prelude::*, }; use std::{ borrow::Cow, @@ -1242,7 +1242,8 @@ impl TransformStrategy for TransformStrategyUnSolid { { match read_entry? { ReadEntry::Solid(s) => { - for n in s.entries(password)? { + let mut read_options = ReadOptions::with_password(password); + for n in s.entries(&mut read_options)? { if let Some(entry) = transformer(n.map(Into::into))? { archive.add_entry(entry)?; } @@ -1286,7 +1287,8 @@ impl TransformStrategy for TransformStrategyKeepSolid { .password(password) .build(), )?; - for n in s.entries(password)? { + let mut read_options = ReadOptions::with_password(password); + for n in s.entries(&mut read_options)? { if let Some(entry) = transformer(n.map(Into::into))? { builder.add_entry(entry)?; } @@ -1389,10 +1391,13 @@ where F: FnMut(io::Result) -> io::Result<()>, { let password = password_provider(); + let mut read_options = ReadOptions::with_password(password); run_read_entries( archive_provider, |entry| match entry? { - ReadEntry::Solid(solid) => solid.entries(password)?.try_for_each(&mut processor), + ReadEntry::Solid(solid) => solid + .entries(&mut read_options)? + .try_for_each(&mut processor), ReadEntry::Normal(regular) => processor(Ok(regular)), }, allow_concatenated_archives, @@ -1410,11 +1415,12 @@ where F: FnMut(io::Result) -> io::Result, { let password = password_provider(); + let mut read_options = ReadOptions::with_password(password); run_read_entries_stoppable( archive_provider, |entry| match entry? { ReadEntry::Solid(solid) => { - for n in solid.entries(password)? { + for n in solid.entries(&mut read_options)? { match processor(n)? { ProcessAction::Continue => {} ProcessAction::Stop => return Ok(ProcessAction::Stop), @@ -1492,11 +1498,12 @@ where F: FnMut(io::Result>>) -> io::Result<()>, { let password = password_provider(); + let mut read_options = ReadOptions::with_password(password); run_read_entries_mem( archives, |entry| match entry? { ReadEntry::Solid(s) => s - .entries(password)? + .entries(&mut read_options)? .try_for_each(|r| processor(r.map(Into::into))), ReadEntry::Normal(r) => processor(Ok(r)), }, @@ -1577,11 +1584,12 @@ where F: FnMut(io::Result>>) -> io::Result, { let password = password_provider(); + let mut read_options = ReadOptions::with_password(password); run_read_entries_mem_stoppable( archives, |entry| match entry? { ReadEntry::Solid(s) => { - for n in s.entries(password)? { + for n in s.entries(&mut read_options)? { match processor(n.map(Into::into))? { ProcessAction::Continue => {} ProcessAction::Stop => return Ok(ProcessAction::Stop), diff --git a/cli/src/command/core/archive_source.rs b/cli/src/command/core/archive_source.rs index a40fe8f17..e98b4998d 100644 --- a/cli/src/command/core/archive_source.rs +++ b/cli/src/command/core/archive_source.rs @@ -2,6 +2,8 @@ use std::borrow::Cow; use std::{fs, io}; +#[cfg(feature = "memmap")] +use pna::ReadOptions; use pna::{NormalEntry, ReadEntry}; use super::TransformStrategy; @@ -90,11 +92,12 @@ impl SplitArchiveReader { password: Option<&[u8]>, mut processor: impl FnMut(io::Result>>) -> io::Result<()>, ) -> io::Result<()> { + let mut read_options = ReadOptions::with_password(password); super::run_read_entries_mem( self.mmaps.iter().map(|m| m.as_ref()), |entry| match entry? { ReadEntry::Solid(s) => s - .entries(password)? + .entries(&mut read_options)? .try_for_each(|r| processor(r.map(Into::into))), ReadEntry::Normal(n) => processor(Ok(n)), }, diff --git a/cli/src/command/diff.rs b/cli/src/command/diff.rs index cffcdad70..7a5fc5051 100644 --- a/cli/src/command/diff.rs +++ b/cli/src/command/diff.rs @@ -269,6 +269,7 @@ fn compare_entry>( let data_kind = entry.header().data_kind(); let path = entry.header().path(); let path_str = path.as_str(); + let mut read_options = ReadOptions::with_password(password); let meta = match fs::symlink_metadata(path) { Ok(meta) => meta, Err(e) if e.kind() == io::ErrorKind::NotFound => { @@ -292,7 +293,7 @@ fn compare_entry>( println!("{}", DiffKind::SizeDiffers.display(path_str)); } else { let fs_file = fs::File::open(path)?; - let archive_reader = entry.reader(ReadOptions::with_password(password))?; + let archive_reader = entry.reader(&mut read_options)?; if !streams_equal(fs_file, archive_reader)? { println!("{}", DiffKind::ContentsDiffer.display(path_str)); } @@ -306,7 +307,7 @@ fn compare_entry>( } DataKind::SymbolicLink if meta.is_symlink() => { let link = fs::read_link(path)?; - let mut reader = entry.reader(ReadOptions::with_password(password))?; + let mut reader = entry.reader(&mut read_options)?; let mut link_str = String::new(); reader.read_to_string(&mut link_str)?; if link.as_path() != Path::new(&link_str) { @@ -317,7 +318,7 @@ fn compare_entry>( println!("{}", DiffKind::TypeMismatch.display(path_str)); } DataKind::HardLink if meta.is_file() => { - let mut reader = entry.reader(ReadOptions::with_password(password))?; + let mut reader = entry.reader(&mut read_options)?; let mut target = String::new(); reader.read_to_string(&mut target)?; diff --git a/cli/src/command/extract.rs b/cli/src/command/extract.rs index f865668fa..170a23273 100644 --- a/cli/src/command/extract.rs +++ b/cli/src/command/extract.rs @@ -1353,11 +1353,12 @@ where return Ok(()); }; + let mut read_options = ReadOptions::with_password(password); if *safe_writes { let mut safe_writer = SafeWriter::new(&path)?; { let mut writer = io::BufWriter::with_capacity(64 * 1024, safe_writer.as_file_mut()); - let mut reader = item.reader(ReadOptions::with_password(password))?; + let mut reader = item.reader(&mut read_options)?; io::copy(&mut reader, &mut writer)?; writer.flush()?; } @@ -1369,7 +1370,7 @@ where } let file = utils::fs::file_create(&path, remove_existing)?; let mut writer = io::BufWriter::with_capacity(64 * 1024, file); - let mut reader = item.reader(ReadOptions::with_password(password))?; + let mut reader = item.reader(&mut read_options)?; io::copy(&mut reader, &mut writer)?; let mut file = writer.into_inner().map_err(|e| e.into_error())?; restore_timestamps(&mut file, item.metadata(), keep_options)?; @@ -1418,9 +1419,10 @@ where return Ok(()); }; + let mut read_options = ReadOptions::with_password(password); match item.header().data_kind() { DataKind::SymbolicLink => { - let reader = item.reader(ReadOptions::with_password(password))?; + let reader = item.reader(&mut read_options)?; let original = io::read_to_string(reader)?; let original = pathname_editor.edit_symlink(original.as_ref()); if !allow_unsafe_links && is_unsafe_link(&original) { @@ -1436,7 +1438,7 @@ where symlink_with_type(&original, &path, link_target_type)?; } DataKind::HardLink => { - let reader = item.reader(ReadOptions::with_password(password))?; + let reader = item.reader(&mut read_options)?; let original = io::read_to_string(reader)?; let Some((original, had_root)) = pathname_editor.edit_hardlink(original.as_ref()) else { @@ -1881,7 +1883,8 @@ where return Ok(()); } - let mut reader = item.reader(ReadOptions::with_password(password))?; + let mut read_options = ReadOptions::with_password(password); + let mut reader = item.reader(&mut read_options)?; let mut stdout = io::stdout().lock(); io::copy(&mut reader, &mut stdout)?; stdout.flush()?; diff --git a/cli/src/command/list.rs b/cli/src/command/list.rs index 9c9c25f0c..067b272d9 100644 --- a/cli/src/command/list.rs +++ b/cli/src/command/list.rs @@ -433,7 +433,7 @@ impl TableRow { // Only read link target if needed (requires decompression) if collect.link_target { entry - .reader(ReadOptions::with_password(password)) + .reader(&mut ReadOptions::with_password(password)) .and_then(io::read_to_string) .unwrap_or_else(|_| "-".into()) } else { @@ -445,7 +445,7 @@ impl TableRow { // Only read link target if needed (requires decompression) if collect.link_target { entry - .reader(ReadOptions::with_password(password)) + .reader(&mut ReadOptions::with_password(password)) .and_then(io::read_to_string) .unwrap_or_else(|_| "-".into()) } else { @@ -559,12 +559,13 @@ fn list_archive(ctx: &crate::cli::GlobalContext, args: ListCommand) -> anyhow::R let password = password.as_deref(); let mut entries = Vec::new(); let collect_opts = CollectOptions::from_list_options(&options); + let mut read_options = ReadOptions::with_password(password); source.for_each_read_entry( #[hooq::skip_all] |entry| { match entry? { ReadEntry::Solid(solid) if options.solid => { - for entry in solid.entries(password)? { + for entry in solid.entries(&mut read_options)? { entries.push(TableRow::from_entry( &entry?, password, @@ -646,6 +647,8 @@ pub(crate) fn run_list_archive<'a>( ) -> anyhow::Result<()> { let collect_opts = CollectOptions::from_list_options(&args); + let mut read_options = ReadOptions::with_password(password); + if !fast_read || files_globs.is_empty() { let mut entries = Vec::new(); run_read_entries( @@ -653,7 +656,7 @@ pub(crate) fn run_list_archive<'a>( |entry| { match entry? { ReadEntry::Solid(solid) if args.solid => { - for entry in solid.entries(password)? { + for entry in solid.entries(&mut read_options)? { entries.push(TableRow::from_entry( &entry?, password, @@ -686,7 +689,7 @@ pub(crate) fn run_list_archive<'a>( |entry| { match entry? { ReadEntry::Solid(solid) if args.solid => { - for entry in solid.entries(password)? { + for entry in solid.entries(&mut read_options)? { let entry = entry?; let entry_path = entry.name().to_string(); if !globs.matches_any_pattern(&entry_path) { diff --git a/cli/tests/cli/stdio/option_ignore_zeros.rs b/cli/tests/cli/stdio/option_ignore_zeros.rs index cfd74523c..a53284739 100644 --- a/cli/tests/cli/stdio/option_ignore_zeros.rs +++ b/cli/tests/cli/stdio/option_ignore_zeros.rs @@ -67,10 +67,10 @@ fn read_archive_entries(path: impl AsRef) -> Vec<(String, String)> { let mut archive = Archive::read_header(fs::File::open(path).unwrap()).unwrap(); archive .entries() - .extract_solid_entries(None) + .extract_solid_entries(&mut ReadOptions::builder().build()) .map(|entry| { let entry = entry.unwrap(); - let mut reader = entry.reader(ReadOptions::builder().build()).unwrap(); + let mut reader = entry.reader(&mut ReadOptions::builder().build()).unwrap(); let mut content = String::new(); reader.read_to_string(&mut content).unwrap(); (entry.name().to_string(), content) @@ -88,13 +88,18 @@ fn read_all_archive_entries_from_bytes(bytes: &[u8]) -> Vec<(String, String)> { Err(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => break, Err(err) => panic!("unexpected archive read error: {err}"), }; - entries.extend(archive.entries().extract_solid_entries(None).map(|entry| { - let entry = entry.unwrap(); - let mut reader = entry.reader(ReadOptions::builder().build()).unwrap(); - let mut content = String::new(); - reader.read_to_string(&mut content).unwrap(); - (entry.name().to_string(), content) - })); + entries.extend( + archive + .entries() + .extract_solid_entries(&mut ReadOptions::builder().build()) + .map(|entry| { + let entry = entry.unwrap(); + let mut reader = entry.reader(&mut ReadOptions::builder().build()).unwrap(); + let mut content = String::new(); + reader.read_to_string(&mut content).unwrap(); + (entry.name().to_string(), content) + }), + ); let _ = archive.into_inner(); } diff --git a/cli/tests/cli/update/no_timestamp_archive.rs b/cli/tests/cli/update/no_timestamp_archive.rs index c37316de1..23b365109 100644 --- a/cli/tests/cli/update/no_timestamp_archive.rs +++ b/cli/tests/cli/update/no_timestamp_archive.rs @@ -75,7 +75,7 @@ fn update_no_timestamp_archive_always_updates() { if entry.header().path().as_str() == text_txt_path { let mut buf = Vec::new(); entry - .reader(ReadOptions::with_password::<&[u8]>(None)) + .reader(&mut ReadOptions::with_password::<&[u8]>(None)) .unwrap() .read_to_end(&mut buf) .unwrap(); diff --git a/cli/tests/cli/update/option_archive_missing_mtime.rs b/cli/tests/cli/update/option_archive_missing_mtime.rs index 7ab275e48..aa02a4a1f 100644 --- a/cli/tests/cli/update/option_archive_missing_mtime.rs +++ b/cli/tests/cli/update/option_archive_missing_mtime.rs @@ -65,7 +65,7 @@ fn update_default_mtime_missing_still_updates() { .expect("entry should exist"); let mut buf = Vec::new(); entry - .reader(ReadOptions::with_password::<&[u8]>(None)) + .reader(&mut ReadOptions::with_password::<&[u8]>(None)) .unwrap() .read_to_end(&mut buf) .unwrap(); @@ -140,7 +140,7 @@ fn update_archive_missing_mtime_exclude_keeps_entry() { .expect("entry should exist"); let mut buf = Vec::new(); entry - .reader(ReadOptions::with_password::<&[u8]>(None)) + .reader(&mut ReadOptions::with_password::<&[u8]>(None)) .unwrap() .read_to_end(&mut buf) .unwrap(); diff --git a/cli/tests/cli/utils/archive.rs b/cli/tests/cli/utils/archive.rs index 25a64561c..bc27b5e56 100644 --- a/cli/tests/cli/utils/archive.rs +++ b/cli/tests/cli/utils/archive.rs @@ -109,7 +109,8 @@ pub fn extract_single_entry( name: &str, ) -> io::Result> { let mut archive = pna::Archive::open(path)?; - let entries = archive.entries().extract_solid_entries(None); + let mut read_options = pna::ReadOptions::with_password::<&[u8]>(None); + let entries = archive.entries().extract_solid_entries(&mut read_options); for entry in entries { let entry = entry?; if entry.header().path() == name { @@ -136,7 +137,8 @@ where { let password = password.into().map(|p| p.as_bytes()); let mut archive = pna::Archive::open(path)?; - let entries = archive.entries().extract_solid_entries(password); + let mut read_options = pna::ReadOptions::with_password(password); + let entries = archive.entries().extract_solid_entries(&mut read_options); for entry in entries { f(entry?); } @@ -146,7 +148,7 @@ where pub fn read_symlink_target(entry: &pna::NormalEntry) -> String { let mut target = Vec::new(); entry - .reader(pna::ReadOptions::with_password::<&[u8]>(None)) + .reader(&mut pna::ReadOptions::with_password::<&[u8]>(None)) .unwrap() .read_to_end(&mut target) .unwrap(); diff --git a/fuzz/fuzz_targets/aes_cbc.rs b/fuzz/fuzz_targets/aes_cbc.rs index 833d7e763..583541e62 100644 --- a/fuzz/fuzz_targets/aes_cbc.rs +++ b/fuzz/fuzz_targets/aes_cbc.rs @@ -14,8 +14,8 @@ fuzz_target!(|data: &[u8]| { let mut builder = EntryBuilder::new_file("fuzz".into(), write_option).unwrap(); builder.write_all(data).unwrap(); let entry = builder.build().unwrap(); - let read_option = ReadOptions::with_password(Some("password")); - let mut reader = entry.reader(read_option).unwrap(); + let mut read_option = ReadOptions::with_password(Some("password")); + let mut reader = entry.reader(&mut read_option).unwrap(); let mut buf = Vec::with_capacity(data.len()); reader.read_to_end(&mut buf).unwrap(); assert_eq!(data, buf); diff --git a/fuzz/fuzz_targets/aes_ctr.rs b/fuzz/fuzz_targets/aes_ctr.rs index 117f5115d..4c1e12f5f 100644 --- a/fuzz/fuzz_targets/aes_ctr.rs +++ b/fuzz/fuzz_targets/aes_ctr.rs @@ -14,8 +14,8 @@ fuzz_target!(|data: &[u8]| { let mut builder = EntryBuilder::new_file("fuzz".into(), write_option).unwrap(); builder.write_all(data).unwrap(); let entry = builder.build().unwrap(); - let read_option = ReadOptions::with_password(Some("password")); - let mut reader = entry.reader(read_option).unwrap(); + let mut read_option = ReadOptions::with_password(Some("password")); + let mut reader = entry.reader(&mut read_option).unwrap(); let mut buf = Vec::with_capacity(data.len()); reader.read_to_end(&mut buf).unwrap(); assert_eq!(data, buf); diff --git a/fuzz/fuzz_targets/camellia_cbc.rs b/fuzz/fuzz_targets/camellia_cbc.rs index 0d935fb84..9de3c57f1 100644 --- a/fuzz/fuzz_targets/camellia_cbc.rs +++ b/fuzz/fuzz_targets/camellia_cbc.rs @@ -14,8 +14,8 @@ fuzz_target!(|data: &[u8]| { let mut builder = EntryBuilder::new_file("fuzz".into(), write_option).unwrap(); builder.write_all(data).unwrap(); let entry = builder.build().unwrap(); - let read_option = ReadOptions::with_password(Some("password")); - let mut reader = entry.reader(read_option).unwrap(); + let mut read_option = ReadOptions::with_password(Some("password")); + let mut reader = entry.reader(&mut read_option).unwrap(); let mut buf = Vec::with_capacity(data.len()); reader.read_to_end(&mut buf).unwrap(); assert_eq!(data, buf); diff --git a/fuzz/fuzz_targets/camellia_ctr.rs b/fuzz/fuzz_targets/camellia_ctr.rs index a9e7ac160..b9acf0553 100644 --- a/fuzz/fuzz_targets/camellia_ctr.rs +++ b/fuzz/fuzz_targets/camellia_ctr.rs @@ -14,8 +14,8 @@ fuzz_target!(|data: &[u8]| { let mut builder = EntryBuilder::new_file("fuzz".into(), write_option).unwrap(); builder.write_all(data).unwrap(); let entry = builder.build().unwrap(); - let read_option = ReadOptions::with_password(Some("password")); - let mut reader = entry.reader(read_option).unwrap(); + let mut read_option = ReadOptions::with_password(Some("password")); + let mut reader = entry.reader(&mut read_option).unwrap(); let mut buf = Vec::with_capacity(data.len()); reader.read_to_end(&mut buf).unwrap(); assert_eq!(data, buf); diff --git a/fuzz/fuzz_targets/split_archive.rs b/fuzz/fuzz_targets/split_archive.rs index 3587e662c..795f56b4e 100644 --- a/fuzz/fuzz_targets/split_archive.rs +++ b/fuzz/fuzz_targets/split_archive.rs @@ -34,10 +34,14 @@ fuzz_target!(|data: (&[u8], usize)| { let archive_bytes = archive.finalize().unwrap(); let mut archive = Archive::read_header_from_slice(&archive_bytes).unwrap(); - for entry in archive.entries_slice().extract_solid_entries(None) { + let mut read_option = ReadOptions::builder().build(); + let entries: Vec<_> = archive + .entries_slice() + .extract_solid_entries(&mut read_option) + .collect(); + for entry in entries { let entry = entry.unwrap(); - let read_option = ReadOptions::builder().build(); - let mut reader = entry.reader(read_option).unwrap(); + let mut reader = entry.reader(&mut read_option).unwrap(); let mut buf = Vec::with_capacity(data.len()); reader.read_to_end(&mut buf).unwrap(); assert_eq!(data, buf); diff --git a/lib/README.md b/lib/README.md index 12d42662b..d8e3de461 100644 --- a/lib/README.md +++ b/lib/README.md @@ -27,7 +27,7 @@ fn main() -> io::Result<()> { for entry in archive.entries().skip_solid() { let entry = entry?; let mut file = File::create(entry.header().path().as_path())?; - let mut reader = entry.reader(ReadOptions::builder().build())?; + let mut reader = entry.reader(&mut ReadOptions::builder().build())?; copy(&mut reader, &mut file)?; } Ok(()) diff --git a/lib/benches/create_extract.rs b/lib/benches/create_extract.rs index 95b4c7d38..7a645b247 100644 --- a/lib/benches/create_extract.rs +++ b/lib/benches/create_extract.rs @@ -41,10 +41,11 @@ fn bench_read_archive(b: &mut Bencher, mut options: WriteOptionsBuilder) { b.iter(|| { let mut reader = Archive::read_header(vec.as_slice()).unwrap(); + let mut read_options = ReadOptions::with_password(Some("password")); for item in reader.entries().skip_solid() { let mut buf = Vec::with_capacity(1000); item.unwrap() - .reader(ReadOptions::with_password(Some("password"))) + .reader(&mut read_options) .unwrap() .read_to_end(&mut buf) .unwrap(); @@ -73,7 +74,7 @@ fn bench_read_archive_from_slice(b: &mut Bencher, mut options: WriteOptionsBuild match item.unwrap() { ReadEntry::Solid(_) => (), ReadEntry::Normal(item) => { - item.reader(ReadOptions::with_password(Some("password"))) + item.reader(&mut ReadOptions::with_password(Some("password"))) .unwrap() .read_to_end(&mut buf) .unwrap(); @@ -308,7 +309,7 @@ fn bench_read_empty_archive(c: &mut Criterion) { for entry in reader.entries().skip_solid() { let item = entry.expect("failed to read entry"); io::read_to_string( - item.reader(ReadOptions::builder().build()) + item.reader(&mut ReadOptions::builder().build()) .expect("failed to read entry"), ) .expect("failed to make string"); diff --git a/lib/examples/async_io.rs b/lib/examples/async_io.rs index 61dab0d6f..7c745798b 100644 --- a/lib/examples/async_io.rs +++ b/lib/examples/async_io.rs @@ -38,16 +38,17 @@ async fn extract(path: String) -> io::Result<()> { while let Some(entry) = archive.read_entry_async().await? { match entry { ReadEntry::Solid(solid_entry) => { - for entry in solid_entry.entries(None)? { + let mut read_options = ReadOptions::builder().build(); + for entry in solid_entry.entries(&mut read_options)? { let entry = entry?; let mut file = io::Cursor::new(Vec::new()); - let mut reader = entry.reader(ReadOptions::builder().build())?.compat(); + let mut reader = entry.reader(&mut ReadOptions::builder().build())?.compat(); tokio::io::copy(&mut reader, &mut file).await?; } } ReadEntry::Normal(entry) => { let mut file = io::Cursor::new(Vec::new()); - let mut reader = entry.reader(ReadOptions::builder().build())?.compat(); + let mut reader = entry.reader(&mut ReadOptions::builder().build())?.compat(); tokio::io::copy(&mut reader, &mut file).await?; } } diff --git a/lib/examples/change_compression_method.rs b/lib/examples/change_compression_method.rs index 08d69fd49..39d8676bd 100644 --- a/lib/examples/change_compression_method.rs +++ b/lib/examples/change_compression_method.rs @@ -16,7 +16,7 @@ fn change_compression_method( header.path().clone(), WriteOptions::builder().compression(compression).build(), )?; - let mut reader = entry.reader(ReadOptions::builder().build())?; + let mut reader = entry.reader(&mut ReadOptions::builder().build())?; io::copy(&mut reader, &mut builder)?; writer.add_entry(builder.build()?)?; } diff --git a/lib/src/archive.rs b/lib/src/archive.rs index 2b05abff8..3264e2f5b 100644 --- a/lib/src/archive.rs +++ b/lib/src/archive.rs @@ -57,10 +57,11 @@ pub(crate) use {read::*, write::*}; /// # fn main() -> io::Result<()> { /// let file = File::open("foo.pna")?; /// let mut archive = Archive::read_header(file)?; +/// let mut read_options = ReadOptions::builder().build(); /// for entry in archive.entries().skip_solid() { /// let entry = entry?; /// let mut file = File::create(entry.header().path().as_path())?; -/// let mut reader = entry.reader(ReadOptions::builder().build())?; +/// let mut reader = entry.reader(&mut read_options)?; /// copy(&mut reader, &mut file)?; /// } /// # Ok(()) @@ -361,11 +362,11 @@ mod tests { } fn archive(src: &[u8], options: WriteOptions) -> io::Result<()> { - let read_options = ReadOptions::with_password(options.password()); + let mut read_options = ReadOptions::with_password(options.password()); let archive = create_archive(src, options)?; let mut archive_reader = Archive::read_header(archive.as_slice())?; let item = archive_reader.entries().skip_solid().next().unwrap()?; - let mut reader = item.reader(read_options)?; + let mut reader = item.reader(&mut read_options)?; let mut dist = Vec::new(); io::copy(&mut reader, &mut dist)?; assert_eq!(src, dist.as_slice()); @@ -395,10 +396,12 @@ mod tests { let mut entries = archive.entries(); let entry = entries.next().unwrap().unwrap(); if let ReadEntry::Solid(entry) = entry { - let mut entries = entry.entries(password.as_deref()).unwrap(); + let mut read_options = ReadOptions::with_password(password.as_deref()); + let mut entries = entry.entries(&mut read_options).unwrap(); for i in 0..200 { let entry = entries.next().unwrap().unwrap(); - let mut reader = entry.reader(ReadOptions::builder().build()).unwrap(); + let mut read_opts = ReadOptions::builder().build(); + let mut reader = entry.reader(&mut read_opts).unwrap(); let mut body = Vec::new(); reader.read_to_end(&mut body).unwrap(); assert_eq!(format!("text{i}").repeat(i).as_bytes(), &body[..]); @@ -447,7 +450,8 @@ mod tests { }; let mut archive_reader = Archive::read_header(archive.as_slice()).unwrap(); - let mut entries = archive_reader.entries_with_password(Some(b"password")); + let mut read_options = ReadOptions::with_password(Some(b"password")); + let mut entries = archive_reader.entries_with_password(&mut read_options); entries.next().unwrap().expect("failed to read entry"); entries.next().unwrap().expect("failed to read entry"); assert!(entries.next().is_none()); @@ -526,7 +530,8 @@ mod tests { let mut archive = Archive::read_header(buf.as_slice()).unwrap(); - let mut entries = archive.entries_with_password(None); + let mut read_options = ReadOptions::builder().build(); + let mut entries = archive.entries_with_password(&mut read_options); let read_entry = entries.next().unwrap().unwrap(); assert_eq!( diff --git a/lib/src/archive/read.rs b/lib/src/archive/read.rs index 774f07985..c095d8bc1 100644 --- a/lib/src/archive/read.rs +++ b/lib/src/archive/read.rs @@ -132,9 +132,9 @@ impl Archive { #[inline] pub fn entries_with_password<'a>( &'a mut self, - password: Option<&'a [u8]>, + option: &'a mut crate::ReadOptions, ) -> impl Iterator> + 'a { - self.entries().extract_solid_entries(password) + self.entries().extract_solid_entries(option) } /// Reads the next archive from the provided reader and returns a new [`Archive`]. @@ -305,16 +305,17 @@ impl<'r, R> Entries<'r, R> { /// # fn main() -> io::Result<()> { /// let file = fs::File::open("foo.pna")?; /// let mut archive = Archive::read_header(file)?; - /// for entry in archive.entries().extract_solid_entries(Some(b"password")) { - /// let mut reader = entry?.reader(ReadOptions::builder().build()); + /// let mut read_options = ReadOptions::with_password(Some(b"password")); + /// for entry in archive.entries().extract_solid_entries(&mut read_options) { + /// let entry = entry?; /// // process the entry /// } /// # Ok(()) /// # } /// ``` #[inline] - pub fn extract_solid_entries(self, password: Option<&'r [u8]>) -> NormalEntries<'r, R> { - NormalEntries::new(self.reader, password) + pub fn extract_solid_entries(self, option: &'r mut crate::ReadOptions) -> NormalEntries<'r, R> { + NormalEntries::new(self.reader, option) } } @@ -360,16 +361,16 @@ impl futures_util::Stream for Entries<'_, R> { /// An iterator over the entries in the archive. pub struct NormalEntries<'r, R> { reader: &'r mut Archive, - password: Option<&'r [u8]>, + option: &'r mut crate::ReadOptions, solid_iter: Option, } impl<'r, R> NormalEntries<'r, R> { #[inline] - pub(crate) fn new(reader: &'r mut Archive, password: Option<&'r [u8]>) -> Self { + pub(crate) fn new(reader: &'r mut Archive, option: &'r mut crate::ReadOptions) -> Self { Self { reader, - password, + option, solid_iter: None, } } @@ -390,7 +391,7 @@ impl Iterator for NormalEntries<'_, R> { match self.reader.read_entry() { Ok(Some(ReadEntry::Normal(entry))) => return Some(Ok(entry)), - Ok(Some(ReadEntry::Solid(entry))) => match entry.into_entries(self.password) { + Ok(Some(ReadEntry::Solid(entry))) => match entry.into_entries(self.option) { Ok(iter) => { self.solid_iter = Some(iter); continue; @@ -488,19 +489,20 @@ mod tests { let input = include_bytes!("../../../resources/test/zstd.pna"); let file = io::Cursor::new(input).compat(); let mut archive = Archive::read_header_async(file).await?; + let mut read_options = ReadOptions::builder().build(); while let Some(entry) = archive.read_entry_async().await? { match entry { ReadEntry::Solid(solid_entry) => { - for entry in solid_entry.entries(None)? { + for entry in solid_entry.entries(&mut read_options)? { let entry = entry?; let mut file = io::Cursor::new(Vec::new()); - let mut reader = entry.reader(ReadOptions::builder().build())?.compat(); + let mut reader = entry.reader(&mut read_options)?.compat(); tokio::io::copy(&mut reader, &mut file).await?; } } ReadEntry::Normal(entry) => { let mut file = io::Cursor::new(Vec::new()); - let mut reader = entry.reader(ReadOptions::builder().build())?.compat(); + let mut reader = entry.reader(&mut read_options)?.compat(); tokio::io::copy(&mut reader, &mut file).await?; } } diff --git a/lib/src/archive/read/slice.rs b/lib/src/archive/read/slice.rs index f5168c55e..bd4bafb3a 100644 --- a/lib/src/archive/read/slice.rs +++ b/lib/src/archive/read/slice.rs @@ -201,11 +201,12 @@ impl<'a, 'r> Entries<'a, 'r> { /// # fn main() -> io::Result<()> { /// let file = fs::read("foo.pna")?; /// let mut archive = Archive::read_header_from_slice(&file[..])?; + /// let mut read_options = ReadOptions::with_password(Some(b"password")); /// for entry in archive /// .entries_slice() - /// .extract_solid_entries(Some(b"password")) + /// .extract_solid_entries(&mut read_options) /// { - /// let mut reader = entry?.reader(ReadOptions::builder().build()); + /// let entry = entry?; /// // process the entry /// } /// # Ok(()) @@ -214,14 +215,11 @@ impl<'a, 'r> Entries<'a, 'r> { #[inline] pub fn extract_solid_entries( self, - password: Option<&'r [u8]>, - ) -> impl Iterator> + 'a - where - 'a: 'r, - { + option: &'a mut crate::ReadOptions, + ) -> impl Iterator> + 'a { self.flat_map(move |f| match f { Ok(ReadEntry::Normal(r)) => vec![Ok(r.into())], - Ok(ReadEntry::Solid(r)) => match r.entries(password) { + Ok(ReadEntry::Solid(r)) => match r.entries(option) { Ok(entries) => entries.collect(), Err(e) => vec![Err(e)], }, @@ -275,7 +273,8 @@ mod tests { let mut entries = archive.entries_slice(); let solid_entry = entries.next().unwrap().unwrap(); if let ReadEntry::Solid(solid_entry) = solid_entry { - let mut entries = solid_entry.entries(None).unwrap(); + let mut read_options = crate::ReadOptions::builder().build(); + let mut entries = solid_entry.entries(&mut read_options).unwrap(); assert!(entries.next().is_some()); assert!(entries.next().is_some()); assert!(entries.next().is_some()); diff --git a/lib/src/archive/write.rs b/lib/src/archive/write.rs index 7ce302bfb..95394daef 100644 --- a/lib/src/archive/write.rs +++ b/lib/src/archive/write.rs @@ -614,13 +614,14 @@ mod tests { .expect("failed to write"); let file = writer.finalize().expect("failed to finalize"); let mut reader = Archive::read_header(&file[..]).expect("failed to read archive"); - let mut entries = reader.entries_with_password(None); + let mut read_options = ReadOptions::builder().build(); + let mut entries = reader.entries_with_password(&mut read_options); let entry = entries .next() .expect("failed to get entry") .expect("failed to read entry"); let mut data_reader = entry - .reader(ReadOptions::builder().build()) + .reader(&mut ReadOptions::builder().build()) .expect("failed to read entry data"); let mut data = Vec::new(); data_reader @@ -643,13 +644,14 @@ mod tests { .expect("failed to write"); let file = writer.finalize().expect("failed to finalize"); let mut reader = Archive::read_header(&file[..]).expect("failed to read archive"); - let mut entries = reader.entries_with_password(None); + let mut read_options = ReadOptions::builder().build(); + let mut entries = reader.entries_with_password(&mut read_options); let entry = entries .next() .expect("failed to get entry") .expect("failed to read entry"); let mut data_reader = entry - .reader(ReadOptions::builder().build()) + .reader(&mut ReadOptions::builder().build()) .expect("failed to read entry data"); let mut data = Vec::new(); data_reader @@ -688,13 +690,14 @@ mod tests { ); let mut reader = Archive::read_header(&file[..]).expect("failed to read archive"); - let mut entries = reader.entries_with_password(None); + let mut read_options = ReadOptions::builder().build(); + let mut entries = reader.entries_with_password(&mut read_options); let entry = entries .next() .expect("failed to get entry") .expect("failed to read entry"); let mut data_reader = entry - .reader(ReadOptions::builder().build()) + .reader(&mut ReadOptions::builder().build()) .expect("failed to read entry data"); let mut data = Vec::new(); data_reader @@ -729,13 +732,14 @@ mod tests { ); let mut reader = Archive::read_header(&file[..]).expect("failed to read archive"); - let mut entries = reader.entries_with_password(None); + let mut read_options = ReadOptions::builder().build(); + let mut entries = reader.entries_with_password(&mut read_options); let entry = entries .next() .expect("failed to get entry") .expect("failed to read entry"); let mut data_reader = entry - .reader(ReadOptions::builder().build()) + .reader(&mut ReadOptions::builder().build()) .expect("failed to read entry data"); let mut data = Vec::new(); data_reader @@ -760,13 +764,14 @@ mod tests { .expect("failed to write"); let file = writer.finalize().expect("failed to finalize"); let mut reader = Archive::read_header(&file[..]).expect("failed to read archive"); - let mut entries = reader.entries_with_password(None); + let mut read_options = ReadOptions::builder().build(); + let mut entries = reader.entries_with_password(&mut read_options); let entry = entries .next() .expect("failed to get entry") .expect("failed to read entry"); let mut data_reader = entry - .reader(ReadOptions::builder().build()) + .reader(&mut ReadOptions::builder().build()) .expect("failed to read entry data"); let mut data = Vec::new(); data_reader @@ -803,13 +808,14 @@ mod tests { ); let mut reader = Archive::read_header(&file[..]).expect("failed to read archive"); - let mut entries = reader.entries_with_password(None); + let mut read_options = ReadOptions::builder().build(); + let mut entries = reader.entries_with_password(&mut read_options); let entry = entries .next() .expect("failed to get entry") .expect("failed to read entry"); let mut data_reader = entry - .reader(ReadOptions::builder().build()) + .reader(&mut ReadOptions::builder().build()) .expect("failed to read entry data"); let mut data = Vec::new(); data_reader diff --git a/lib/src/entry.rs b/lib/src/entry.rs index f1e42d358..c8805c6d6 100644 --- a/lib/src/entry.rs +++ b/lib/src/entry.rs @@ -3,6 +3,7 @@ mod attr; mod builder; mod header; +mod key_cache; mod meta; mod name; mod options; @@ -10,6 +11,7 @@ mod read; mod reference; mod write; +pub(crate) use self::key_cache::KeyCache; pub use self::{ attr::*, builder::{EntryBuilder, SolidEntryBuilder}, @@ -411,12 +413,13 @@ impl> SolidEntry { /// # fn main() -> io::Result<()> { /// let file = fs::File::open("foo.pna")?; /// let mut archive = Archive::read_header(file)?; + /// let mut read_options = ReadOptions::with_password(Some(b"password")); /// for entry in archive.entries() { /// match entry? { /// ReadEntry::Solid(solid_entry) => { - /// for entry in solid_entry.entries(Some(b"password"))? { + /// for entry in solid_entry.entries(&mut read_options)? { /// let entry = entry?; - /// let mut reader = entry.reader(ReadOptions::builder().build()); + /// let mut reader = entry.reader(&mut read_options); /// // process the entry /// } /// } @@ -431,14 +434,15 @@ impl> SolidEntry { #[inline] pub fn entries( &self, - password: Option<&[u8]>, + option: &mut ReadOptions, ) -> io::Result> + '_> { let reader = decrypt_reader( ChainReader::new(self.data.iter().map(AsRef::as_ref as fn(&T) -> &[u8])), self.header.encryption, self.header.cipher_mode, self.phsf.as_deref(), - password, + option.password.as_deref(), + &mut option.cache, )?; let reader = decompress_reader(reader, self.header.compression)?; @@ -454,7 +458,7 @@ where /// /// This variant owns the underlying buffers, enabling streaming without borrowing from `self`. #[inline] - pub(crate) fn into_entries(self, password: Option<&[u8]>) -> io::Result { + pub(crate) fn into_entries(self, option: &mut ReadOptions) -> io::Result { let bufs = self .data .into_iter() @@ -466,7 +470,8 @@ where self.header.encryption, self.header.cipher_mode, self.phsf.as_deref(), - password, + option.password.as_deref(), + &mut option.cache, )?; let reader = decompress_reader(reader, self.header.compression)?; Ok(SolidIntoEntries(EntryReader(reader))) @@ -1029,9 +1034,10 @@ impl> NormalEntry { /// # fn main() -> io::Result<()> { /// let file = fs::File::open("foo.pna")?; /// let mut archive = Archive::read_header(file)?; + /// let mut read_options = ReadOptions::builder().build(); /// for entry in archive.entries().skip_solid() { /// let entry = entry?; - /// let mut reader = entry.reader(ReadOptions::builder().build())?; + /// let mut reader = entry.reader(&mut read_options)?; /// let name = entry.header().path(); /// let mut dist_file = fs::File::create(name)?; /// io::copy(&mut reader, &mut dist_file)?; @@ -1040,7 +1046,7 @@ impl> NormalEntry { /// # } /// ``` #[inline] - pub fn reader(&self, option: impl ReadOption) -> io::Result> { + pub fn reader(&self, option: &mut ReadOptions) -> io::Result> { let raw_data_reader = ChainReader::new( self.data .iter() @@ -1052,7 +1058,8 @@ impl> NormalEntry { self.header.encryption, self.header.cipher_mode, self.phsf.as_deref(), - option.password(), + option.password.as_deref(), + &mut option.cache, )?; let reader = decompress_reader(decrypt_reader, self.header.compression)?; Ok(EntryDataReader(EntryReader(reader))) diff --git a/lib/src/entry/builder.rs b/lib/src/entry/builder.rs index eb8a5dcec..9f6f22b61 100644 --- a/lib/src/entry/builder.rs +++ b/lib/src/entry/builder.rs @@ -745,9 +745,10 @@ mod tests { }) .unwrap(); let solid_entry = builder.build_as_entry().unwrap(); - let mut entries = solid_entry.entries(None).unwrap(); + let mut read_options = ReadOptions::builder().build(); + let mut entries = solid_entry.entries(&mut read_options).unwrap(); let entry = entries.next().unwrap().unwrap(); - let mut reader = entry.reader(ReadOptions::builder().build()).unwrap(); + let mut reader = entry.reader(&mut ReadOptions::builder().build()).unwrap(); let mut buf = Vec::new(); reader.read_to_end(&mut buf).unwrap(); assert_eq!(b"abcdefghijklmnopqrstuvwxyz", &buf[..]); @@ -767,9 +768,10 @@ mod tests { .unwrap(); let solid_entry = builder.build_as_entry().unwrap(); - let mut entries = solid_entry.entries(None).unwrap(); + let mut read_options = ReadOptions::builder().build(); + let mut entries = solid_entry.entries(&mut read_options).unwrap(); let entry = entries.next().unwrap().unwrap(); - let mut reader = entry.reader(ReadOptions::builder().build()).unwrap(); + let mut reader = entry.reader(&mut ReadOptions::builder().build()).unwrap(); let mut buf = Vec::new(); reader.read_to_end(&mut buf).unwrap(); diff --git a/lib/src/entry/key_cache.rs b/lib/src/entry/key_cache.rs new file mode 100644 index 000000000..23c976f58 --- /dev/null +++ b/lib/src/entry/key_cache.rs @@ -0,0 +1,128 @@ +//! LRU cache for derived password keys. + +use password_hash::Output; +use std::fmt; + +/// LRU cache of derived keys. Capacity 8, keyed by PHSF string. +/// +/// # Safety assumption +/// +/// This cache assumes all lookups use the same password. +/// The cache key is the PHSF string only (not the password). +/// Do not reuse a `KeyCache` across different passwords. +#[derive(Clone, Default)] +pub(crate) struct KeyCache { + entries: Vec<(String, Output)>, +} + +impl KeyCache { + const CAPACITY: usize = 8; + + /// Look up a cached derived key by PHSF string. + /// Moves the hit entry to front (MRU) on success. + pub(crate) fn get(&mut self, phsf: &str) -> Option { + if let Some(pos) = self.entries.iter().position(|(k, _)| k == phsf) { + let entry = self.entries.remove(pos); + let output = entry.1; + self.entries.insert(0, entry); + Some(output) + } else { + None + } + } + + /// Insert a derived key. Evicts LRU (last) entry if at capacity. + pub(crate) fn insert(&mut self, phsf: String, key: Output) { + self.entries.retain(|(k, _)| k != &phsf); + if self.entries.len() >= Self::CAPACITY { + self.entries.pop(); + } + self.entries.insert(0, (phsf, key)); + } +} + +impl fmt::Debug for KeyCache { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("KeyCache") + .field("len", &self.entries.len()) + .field("capacity", &Self::CAPACITY) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dummy_output(seed: u8) -> Output { + Output::new(&[seed; 32]).unwrap() + } + + #[test] + fn get_returns_none_for_empty_cache() { + let mut cache = KeyCache::default(); + assert!(cache.get("$argon2id$...").is_none()); + } + + #[test] + fn insert_and_get_returns_cached_key() { + let mut cache = KeyCache::default(); + let key = dummy_output(1); + cache.insert("phsf1".to_string(), key); + assert_eq!(cache.get("phsf1").unwrap(), key); + } + + #[test] + fn get_miss_for_different_phsf() { + let mut cache = KeyCache::default(); + cache.insert("phsf1".to_string(), dummy_output(1)); + assert!(cache.get("phsf2").is_none()); + } + + #[test] + fn evicts_lru_at_capacity() { + let mut cache = KeyCache::default(); + for i in 0..8 { + cache.insert(format!("phsf{i}"), dummy_output(i)); + } + // Insert 9th — should evict the LRU (phsf0 at back) + cache.insert("phsf_new".to_string(), dummy_output(99)); + assert!(cache.get("phsf_new").is_some()); + // phsf0 was inserted first and is now at back → evicted + assert!(cache.get("phsf0").is_none()); + } + + #[test] + fn get_promotes_entry_to_mru() { + let mut cache = KeyCache::default(); + for i in 0..8 { + cache.insert(format!("phsf{i}"), dummy_output(i)); + } + // Access phsf0 to promote it + assert!(cache.get("phsf0").is_some()); + // Insert new → should evict phsf1 (now LRU), not phsf0 + cache.insert("phsf_new".to_string(), dummy_output(99)); + assert!(cache.get("phsf0").is_some()); + assert!(cache.get("phsf1").is_none()); + } + + #[test] + fn insert_same_key_updates_value() { + let mut cache = KeyCache::default(); + let key1 = dummy_output(1); + let key2 = dummy_output(2); + cache.insert("phsf1".to_string(), key1); + cache.insert("phsf1".to_string(), key2); + assert_eq!(cache.get("phsf1").unwrap(), key2); + } + + #[test] + fn debug_redacts_keys() { + let mut cache = KeyCache::default(); + cache.insert("phsf1".to_string(), dummy_output(1)); + let debug = format!("{cache:?}"); + assert!(debug.contains("len: 1")); + assert!(debug.contains("capacity: 8")); + assert!(!debug.contains("phsf1")); + } +} diff --git a/lib/src/entry/options.rs b/lib/src/entry/options.rs index d9e321347..7fb4bce0d 100644 --- a/lib/src/entry/options.rs +++ b/lib/src/entry/options.rs @@ -1,5 +1,6 @@ //! Read and write options for archive entries. +use super::KeyCache; use crate::{compress, error::UnknownValueError}; pub(crate) use private::*; use std::{fmt, io, str::FromStr}; @@ -142,25 +143,6 @@ mod private { T::cipher(self) } } - - /// Entry read option getter trait. - pub trait ReadOption { - fn password(&self) -> Option<&[u8]>; - } - - impl ReadOption for &T { - #[inline] - fn password(&self) -> Option<&[u8]> { - T::password(self) - } - } - - impl ReadOption for ReadOptions { - #[inline] - fn password(&self) -> Option<&[u8]> { - self.password.as_deref() - } - } } /// A pre-hashed password for efficient encrypted archive creation. @@ -882,9 +864,20 @@ impl WriteOptionsBuilder { } /// Options for reading an entry. -#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] +#[derive(Clone)] pub struct ReadOptions { - password: Option>, + pub(crate) password: Option>, + pub(crate) cache: KeyCache, +} + +impl fmt::Debug for ReadOptions { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ReadOptions") + .field("password", &self.password.as_ref().map(|_| "[REDACTED]")) + .field("cache", &self.cache) + .finish() + } } impl ReadOptions { @@ -907,6 +900,7 @@ impl ReadOptions { pub fn with_password>(password: Option) -> Self { Self { password: password.map(|p| p.as_ref().to_vec()), + cache: KeyCache::default(), } } @@ -939,7 +933,7 @@ impl ReadOptions { } /// Builder for [`ReadOptions`]. -#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)] +#[derive(Clone, Debug, Default)] pub struct ReadOptionsBuilder { password: Option>, } @@ -965,6 +959,59 @@ impl ReadOptionsBuilder { pub fn build(&self) -> ReadOptions { ReadOptions { password: self.password.clone(), + cache: KeyCache::default(), } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn write_options_round_trip_raw_password() { + let opts = WriteOptions::builder() + .encryption(Encryption::Aes) + .password(Some("secret")) + .build(); + let rebuilt = opts.into_builder().build(); + assert_eq!(rebuilt.encryption(), Encryption::Aes); + } + + #[test] + fn write_options_round_trip_hashed_password() { + let hashed = HashedPassword::new(b"secret", HashAlgorithm::argon2id()).unwrap(); + let opts = WriteOptions::builder() + .encryption(Encryption::Aes) + .hashed_password(&hashed) + .build(); + // Round-trip must not panic + let rebuilt = opts.into_builder().build(); + assert_eq!(rebuilt.encryption(), Encryption::Aes); + } + + #[test] + fn hashed_password_last_wins_over_raw() { + let hashed = HashedPassword::new(b"secret", HashAlgorithm::argon2id()).unwrap(); + let opts = WriteOptions::builder() + .encryption(Encryption::Aes) + .password(Some("raw")) + .hashed_password(&hashed) + .build(); + // Should use hashed path — password() returns None + assert!(opts.password().is_none()); + assert_eq!(opts.encryption(), Encryption::Aes); + } + + #[test] + fn raw_password_last_wins_over_hashed() { + let hashed = HashedPassword::new(b"secret", HashAlgorithm::argon2id()).unwrap(); + let opts = WriteOptions::builder() + .encryption(Encryption::Aes) + .hashed_password(&hashed) + .password(Some("raw")) + .build(); + // Should use raw path — password() returns Some + assert!(opts.password().is_some()); + } +} diff --git a/lib/src/entry/read.rs b/lib/src/entry/read.rs index d2c021779..646c10cb7 100644 --- a/lib/src/entry/read.rs +++ b/lib/src/entry/read.rs @@ -4,6 +4,7 @@ use crate::{ CipherMode, Compression, Encryption, cipher::{Ctr128BEReader, DecryptCbcAes256Reader, DecryptCbcCamellia256Reader, DecryptReader}, compress::DecompressReader, + entry::KeyCache, hash::derive_password_hash, }; use aes::Aes256; @@ -18,6 +19,7 @@ pub(crate) fn decrypt_reader( cipher_mode: CipherMode, phsf: Option<&str>, password: Option<&[u8]>, + cache: &mut KeyCache, ) -> io::Result> { Ok(match encryption { Encryption::No => DecryptReader::No(reader), @@ -25,16 +27,22 @@ pub(crate) fn decrypt_reader( let s = phsf.ok_or_else(|| { io::Error::new(io::ErrorKind::InvalidData, "`PHSF` chunk not found") })?; - let phsf = derive_password_hash( - s, - password.ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidInput, "password was not provided") - })?, - )?; - let hash = phsf - .hash - .ok_or_else(|| io::Error::new(io::ErrorKind::Unsupported, "failed to get hash"))?; - let key = hash.as_bytes(); + let key = if let Some(cached) = cache.get(s) { + cached + } else { + let phsf_output = derive_password_hash( + s, + password.ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "password was not provided") + })?, + )?; + let hash = phsf_output.hash.ok_or_else(|| { + io::Error::new(io::ErrorKind::Unsupported, "failed to get hash") + })?; + cache.insert(s.to_string(), hash); + hash + }; + let key = key.as_bytes(); match (encryption, cipher_mode) { (Encryption::Aes, CipherMode::CBC) => { let mut iv = vec![0; Aes256::block_size()]; diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 4aec43aa2..ba10227a3 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -51,7 +51,7 @@ //! println!("Entry: {}", entry.header().path().as_path().display()); //! //! // Read file contents -//! let mut reader = entry.reader(ReadOptions::builder().build())?; +//! let mut reader = entry.reader(&mut ReadOptions::builder().build())?; //! let mut contents = Vec::new(); //! reader.read_to_end(&mut contents)?; //! } diff --git a/lib/tests/extract_compatibility.rs b/lib/tests/extract_compatibility.rs index ed9da988b..5b2f0cb5e 100644 --- a/lib/tests/extract_compatibility.rs +++ b/lib/tests/extract_compatibility.rs @@ -10,7 +10,8 @@ fn extract_all(bytes: &[u8], password: Option<&[u8]>) { } let path = item.header().path().as_str(); let mut dist = Vec::new(); - let mut reader = item.reader(ReadOptions::with_password(password)).unwrap(); + let mut read_options = ReadOptions::with_password(password); + let mut reader = item.reader(&mut read_options).unwrap(); io::copy(&mut reader, &mut dist).unwrap(); match path { "raw/first/second/third/pna.txt" => { diff --git a/lib/tests/extract_multipart_compatibility.rs b/lib/tests/extract_multipart_compatibility.rs index 0b1f75a40..8b71d7e5e 100644 --- a/lib/tests/extract_multipart_compatibility.rs +++ b/lib/tests/extract_multipart_compatibility.rs @@ -13,7 +13,8 @@ fn extract_all(follows: &[&[u8]], password: Option<&str>) { } let path = item.header().path().to_string(); let mut dist = Vec::new(); - let mut reader = item.reader(ReadOptions::with_password(password)).unwrap(); + let mut read_options = ReadOptions::with_password(password); + let mut reader = item.reader(&mut read_options).unwrap(); io::copy(&mut reader, &mut dist).unwrap(); match &*path { "multipart_test.txt" => assert_eq!( diff --git a/lib/tests/extract_solid_compatibility.rs b/lib/tests/extract_solid_compatibility.rs index 35e682d8e..af720fb04 100644 --- a/lib/tests/extract_solid_compatibility.rs +++ b/lib/tests/extract_solid_compatibility.rs @@ -4,7 +4,8 @@ use std::io; fn assert_entry(item: NormalEntry, password: Option<&[u8]>) { let path = item.header().path().as_str(); let mut dist = Vec::new(); - let mut reader = item.reader(ReadOptions::with_password(password)).unwrap(); + let mut read_options = ReadOptions::with_password(password); + let mut reader = item.reader(&mut read_options).unwrap(); io::copy(&mut reader, &mut dist).unwrap(); match path { "raw/first/second/third/pna.txt" => assert_eq!( @@ -50,7 +51,8 @@ fn assert_entry(item: NormalEntry, password: Option<&[u8]>) { fn extract_all(bytes: &[u8], password: Option<&[u8]>) { let mut n = 0; let mut archive_reader = Archive::read_header(bytes).unwrap(); - for entry in archive_reader.entries_with_password(password) { + let mut read_options = ReadOptions::with_password(password); + for entry in archive_reader.entries_with_password(&mut read_options) { let item = entry.unwrap(); if item.header().data_kind() == DataKind::Directory { continue; @@ -66,7 +68,8 @@ fn extract_all(bytes: &[u8], password: Option<&[u8]>) { let item = entry.unwrap(); match item { ReadEntry::Solid(item) => { - for item in item.entries(password).unwrap() { + let mut read_options = ReadOptions::with_password(password); + for item in item.entries(&mut read_options).unwrap() { let item = item.unwrap(); if item.header().data_kind() == DataKind::Directory { continue; diff --git a/pna/README.md b/pna/README.md index c918425e2..5a2a7b1c9 100644 --- a/pna/README.md +++ b/pna/README.md @@ -27,7 +27,7 @@ fn main() -> io::Result<()> { for entry in archive.entries().skip_solid() { let entry = entry?; let mut file = File::create(entry.header().path().as_path())?; - let mut reader = entry.reader(ReadOptions::builder().build())?; + let mut reader = entry.reader(&mut ReadOptions::builder().build())?; copy(&mut reader, &mut file)?; } Ok(()) From 2d510ae392c905745cbec9bdfb9ecf604195b0a7 Mon Sep 17 00:00:00 2001 From: ChanTsune <41658782+ChanTsune@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:34:52 +0900 Subject: [PATCH 3/3] :white_check_mark: Add tests for HashedPassword and key cache --- lib/tests/hashed_password.rs | 192 +++++++++++++++++++++++++++++++++++ lib/tests/security.rs | 35 +++++++ 2 files changed, 227 insertions(+) create mode 100644 lib/tests/hashed_password.rs create mode 100644 lib/tests/security.rs diff --git a/lib/tests/hashed_password.rs b/lib/tests/hashed_password.rs new file mode 100644 index 000000000..cf8d941e9 --- /dev/null +++ b/lib/tests/hashed_password.rs @@ -0,0 +1,192 @@ +use libpna::*; +use std::io::{self, Cursor, Read, Write}; + +#[test] +fn round_trip_with_hashed_password() -> io::Result<()> { + let password = b"test_password"; + let hashed = HashedPassword::new(password, HashAlgorithm::argon2id())?; + + let mut buf = Vec::new(); + { + let mut archive = Archive::write_header(Cursor::new(&mut buf))?; + for i in 0..3 { + let opts = WriteOptions::builder() + .compression(Compression::No) + .encryption(Encryption::Aes) + .hashed_password(&hashed) + .build(); + let mut entry = EntryBuilder::new_file(format!("file{i}.txt").into(), opts)?; + write!(entry, "content {i}")?; + archive.add_entry(entry.build()?)?; + } + archive.finalize()?; + } + + let mut archive = Archive::read_header(Cursor::new(&buf))?; + let mut read_opts = ReadOptions::with_password(Some(password)); + let mut contents = Vec::new(); + for entry in archive.entries().skip_solid() { + let entry = entry?; + let mut reader = entry.reader(&mut read_opts)?; + let mut s = String::new(); + reader.read_to_string(&mut s)?; + contents.push(s); + } + assert_eq!(contents, vec!["content 0", "content 1", "content 2"]); + Ok(()) +} + +#[test] +fn round_trip_with_raw_password_backward_compat() -> io::Result<()> { + let password = "test_password"; + + let mut buf = Vec::new(); + { + let opts = WriteOptions::builder() + .compression(Compression::No) + .encryption(Encryption::Aes) + .password(Some(password)) + .build(); + let mut archive = Archive::write_header(Cursor::new(&mut buf))?; + let mut entry = EntryBuilder::new_file("file.txt".into(), opts)?; + write!(entry, "hello")?; + archive.add_entry(entry.build()?)?; + archive.finalize()?; + } + + let mut archive = Archive::read_header(Cursor::new(&buf))?; + let mut read_opts = ReadOptions::with_password(Some(password)); + for entry in archive.entries().skip_solid() { + let entry = entry?; + let mut reader = entry.reader(&mut read_opts)?; + let mut s = String::new(); + reader.read_to_string(&mut s)?; + assert_eq!(s, "hello"); + } + Ok(()) +} + +#[test] +fn round_trip_with_hashed_password_camellia() -> io::Result<()> { + let password = b"test_password"; + let hashed = HashedPassword::new(password, HashAlgorithm::argon2id())?; + + let mut buf = Vec::new(); + { + let mut archive = Archive::write_header(Cursor::new(&mut buf))?; + let opts = WriteOptions::builder() + .compression(Compression::No) + .encryption(Encryption::Camellia) + .hashed_password(&hashed) + .build(); + let mut entry = EntryBuilder::new_file("file.txt".into(), opts)?; + write!(entry, "camellia content")?; + archive.add_entry(entry.build()?)?; + archive.finalize()?; + } + + let mut archive = Archive::read_header(Cursor::new(&buf))?; + let mut read_opts = ReadOptions::with_password(Some(password)); + for entry in archive.entries().skip_solid() { + let entry = entry?; + let mut reader = entry.reader(&mut read_opts)?; + let mut s = String::new(); + reader.read_to_string(&mut s)?; + assert_eq!(s, "camellia content"); + } + Ok(()) +} + +#[test] +fn round_trip_with_hashed_password_cbc() -> io::Result<()> { + let password = b"test_password"; + let hashed = HashedPassword::new(password, HashAlgorithm::argon2id())?; + + let mut buf = Vec::new(); + { + let mut archive = Archive::write_header(Cursor::new(&mut buf))?; + let opts = WriteOptions::builder() + .compression(Compression::No) + .encryption(Encryption::Aes) + .cipher_mode(CipherMode::CBC) + .hashed_password(&hashed) + .build(); + let mut entry = EntryBuilder::new_file("file.txt".into(), opts)?; + write!(entry, "cbc content")?; + archive.add_entry(entry.build()?)?; + archive.finalize()?; + } + + let mut archive = Archive::read_header(Cursor::new(&buf))?; + let mut read_opts = ReadOptions::with_password(Some(password)); + for entry in archive.entries().skip_solid() { + let entry = entry?; + let mut reader = entry.reader(&mut read_opts)?; + let mut s = String::new(); + reader.read_to_string(&mut s)?; + assert_eq!(s, "cbc content"); + } + Ok(()) +} + +#[test] +fn round_trip_with_hashed_password_pbkdf2() -> io::Result<()> { + let password = b"test_password"; + let hashed = HashedPassword::new(password, HashAlgorithm::pbkdf2_sha256())?; + + let mut buf = Vec::new(); + { + let mut archive = Archive::write_header(Cursor::new(&mut buf))?; + let opts = WriteOptions::builder() + .compression(Compression::No) + .encryption(Encryption::Aes) + .hashed_password(&hashed) + .build(); + let mut entry = EntryBuilder::new_file("file.txt".into(), opts)?; + write!(entry, "pbkdf2 content")?; + archive.add_entry(entry.build()?)?; + archive.finalize()?; + } + + let mut archive = Archive::read_header(Cursor::new(&buf))?; + let mut read_opts = ReadOptions::with_password(Some(password)); + for entry in archive.entries().skip_solid() { + let entry = entry?; + let mut reader = entry.reader(&mut read_opts)?; + let mut s = String::new(); + reader.read_to_string(&mut s)?; + assert_eq!(s, "pbkdf2 content"); + } + Ok(()) +} + +#[test] +fn wrong_password_returns_error() -> io::Result<()> { + let hashed = HashedPassword::new(b"correct_password", HashAlgorithm::argon2id())?; + + let mut buf = Vec::new(); + { + let mut archive = Archive::write_header(Cursor::new(&mut buf))?; + let opts = WriteOptions::builder() + .compression(Compression::ZStandard) + .encryption(Encryption::Aes) + .hashed_password(&hashed) + .build(); + let mut entry = EntryBuilder::new_file("secret.txt".into(), opts)?; + write!(entry, "secret data")?; + archive.add_entry(entry.build()?)?; + archive.finalize()?; + } + + let mut archive = Archive::read_header(Cursor::new(&buf))?; + let mut read_opts = ReadOptions::with_password(Some("wrong_password")); + for entry in archive.entries().skip_solid() { + let entry = entry?; + let mut reader = entry.reader(&mut read_opts)?; + let mut s = String::new(); + // With compression enabled, wrong password should produce invalid compressed + // data, which causes a decompression error. + assert!(reader.read_to_string(&mut s).is_err()); + } + Ok(()) +} diff --git a/lib/tests/security.rs b/lib/tests/security.rs new file mode 100644 index 000000000..044b962a6 --- /dev/null +++ b/lib/tests/security.rs @@ -0,0 +1,35 @@ +use libpna::*; + +#[test] +fn hashed_password_debug_does_not_leak_key() { + let hp = HashedPassword::new(b"secret", HashAlgorithm::argon2id()).unwrap(); + let debug = format!("{hp:?}"); + assert!(debug.contains("[REDACTED]"), "Debug must redact key"); + assert!(!debug.contains("secret"), "Debug must not contain password"); +} + +#[test] +fn read_options_debug_does_not_leak_password() { + let opts = ReadOptions::with_password(Some("secret")); + let debug = format!("{opts:?}"); + assert!(!debug.contains("secret"), "Debug must not contain password"); +} + +#[test] +fn write_options_hashed_round_trip_no_panic() { + let hashed = HashedPassword::new(b"password", HashAlgorithm::argon2id()).unwrap(); + let opts = WriteOptions::builder() + .encryption(Encryption::Aes) + .hashed_password(&hashed) + .build(); + let _rebuilt = opts.into_builder().build(); +} + +#[test] +fn write_options_raw_round_trip_no_panic() { + let opts = WriteOptions::builder() + .encryption(Encryption::Aes) + .password(Some("password")) + .build(); + let _rebuilt = opts.into_builder().build(); +}