diff --git a/Cargo.lock b/Cargo.lock index 774575fe..92e05258 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1405,6 +1405,18 @@ dependencies = [ "version_check", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -1612,9 +1624,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.16" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ "bytes", "getrandom 0.4.3", @@ -1900,9 +1912,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "once_cell", "ring", @@ -1924,9 +1936,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -2720,9 +2732,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.8" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] @@ -2934,14 +2946,15 @@ dependencies = [ [[package]] name = "zip" -version = "8.6.0" +version = "9.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e" dependencies = [ "crc32fast", "flate2", "indexmap", "memchr", + "nix", "typed-path", ] diff --git a/Cargo.toml b/Cargo.toml index 7758e218..8da3156b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,7 @@ quick-xml = { version = "0.42", features = ["escape-html"] } # pulling every codec (bzip2/zstd/lzma/aes-crypto chains incl. zstd-sys's C # build) and zip's optional `time`. The zlib-rs backend keeps flate2 pure-Rust # (no C toolchain) and matches the backend the old default features already used. -zip = { version = "8.6", default-features = false, features = ["deflate-flate2-zlib-rs"] } +zip = { version = "9.0", default-features = false, features = ["deflate-flate2-zlib-rs"] } # Held below 0.40 deliberately: 0.40 pulls a libsqlite3-sys that uses the # `cfg_select!` macro, which does not exist in our MSRV (see docs/msrv-audit.md # for why the floor is 1.88 and what it buys). Raising past 0.39 means raising diff --git a/crates/bookforge-audio/src/atomic.rs b/crates/bookforge-audio/src/atomic.rs index e0efb5d5..5cb08a0f 100644 --- a/crates/bookforge-audio/src/atomic.rs +++ b/crates/bookforge-audio/src/atomic.rs @@ -29,7 +29,7 @@ pub fn replace_file(staged: &Path, destination: &Path) -> io::Result<()> { Err(_) => {} } } - return replace_file_windows(staged, destination); + replace_file_windows(staged, destination) } #[cfg(not(windows))] diff --git a/crates/bookforge-epub/src/archive_limits.rs b/crates/bookforge-epub/src/archive_limits.rs index 51e2ab62..bd1e3e92 100644 --- a/crates/bookforge-epub/src/archive_limits.rs +++ b/crates/bookforge-epub/src/archive_limits.rs @@ -12,7 +12,7 @@ use std::{ fs::File, - io::{Read, Seek, SeekFrom}, + io::{ErrorKind, Read, Seek, SeekFrom}, path::Path, }; @@ -256,7 +256,16 @@ impl ArchiveReadBudget { // without allowing the decompressor or Vec to consume the rest of an attacker stream. let mut bounded = reader.take(read_limit.saturating_add(1)); let mut bytes = Vec::with_capacity(read_limit.min(64 * 1024) as usize); - bounded.read_to_end(&mut bytes)?; + bounded.read_to_end(&mut bytes).map_err(|error| { + // zip itself refuses to inflate an entry past its declared + // uncompressed size, reporting InvalidData before this budget + // trips. Name the entry, as the budget's own limit errors do. + if error.kind() == ErrorKind::InvalidData { + limit_error(format!("failed for '{name}': {error}")) + } else { + error.into() + } + })?; let bytes_read = bytes.len() as u64; if bytes_read > read_limit { @@ -304,7 +313,7 @@ pub fn validate_archive_metadata( let mut total_compressed = 0u64; for index in 0..archive.len() { let file = archive.by_index_raw(index)?; - let name = file.name().to_string(); + let name = file.name()?.into_owned(); let uncompressed = file.size(); let compressed = file.compressed_size(); if uncompressed > limits.max_entry_uncompressed_size { @@ -434,10 +443,12 @@ mod tests { .read_entry(&mut file, "liar.xhtml", compressed_size) .expect_err("bounded read must catch actual expansion"); + // zip stops at the declared 1 KiB, before the per-entry limit. + let message = error.to_string(); assert!( - error - .to_string() - .contains("per-entry uncompressed size limit exceeded for 'liar.xhtml'") + message.contains("EPUB decompression failed for 'liar.xhtml'") + && message.contains("declared uncompressed size"), + "{message}" ); } diff --git a/crates/bookforge-epub/src/reader.rs b/crates/bookforge-epub/src/reader.rs index 8cf706c0..cb96a2ca 100644 --- a/crates/bookforge-epub/src/reader.rs +++ b/crates/bookforge-epub/src/reader.rs @@ -308,8 +308,8 @@ pub fn inspect_epub(path: &Path) -> Result { // covers hostile archives). let archive_names = archive .file_names() - .map(|name| name.to_string()) - .collect::>(); + .map(|name| name.map(String::from)) + .collect::, _>>()?; let mut xhtml_spine_count = 0; for item in &package.spine { diff --git a/crates/bookforge-epub/src/reflow.rs b/crates/bookforge-epub/src/reflow.rs index 27ad4e63..7ffd17fc 100644 --- a/crates/bookforge-epub/src/reflow.rs +++ b/crates/bookforge-epub/src/reflow.rs @@ -177,7 +177,7 @@ fn write_archive_entries( ) -> Result<()> { for index in 0..archive.len() { let mut file = archive.by_index(index)?; - let name = file.name().to_string(); + let name = file.name()?.into_owned(); if name == "mimetype" { continue; diff --git a/crates/bookforge-epub/src/validate.rs b/crates/bookforge-epub/src/validate.rs index 111e7b87..e4e223fb 100644 --- a/crates/bookforge-epub/src/validate.rs +++ b/crates/bookforge-epub/src/validate.rs @@ -82,7 +82,9 @@ pub fn validate_translated_epub( let Ok(mut entry) = archive.by_index(index) else { continue; }; - let name = entry.name().to_string(); + let Ok(name) = entry.name().map(|name| name.into_owned()) else { + continue; + }; if !is_validatable_resource_name(&name) { file_names.push(name); continue; diff --git a/crates/bookforge-epub/src/writer.rs b/crates/bookforge-epub/src/writer.rs index 0bb61190..5ad977b7 100644 --- a/crates/bookforge-epub/src/writer.rs +++ b/crates/bookforge-epub/src/writer.rs @@ -150,7 +150,7 @@ fn write_rebuilt_epub( // A raw (metadata-only) handle answers cheap questions — name, // directory-ness, declared compression — before any bytes move. let raw = archive.by_index_raw(index)?; - let name = raw.name().to_string(); + let name = raw.name()?.into_owned(); if name == "mimetype" { continue; @@ -324,7 +324,7 @@ fn normalized_entry_options(compression_method: CompressionMethod) -> SimpleFile fn archive_entry_names(archive: &mut ZipArchive) -> Result> { let mut names = HashSet::new(); for index in 0..archive.len() { - names.insert(archive.by_index_raw(index)?.name().to_string()); + names.insert(archive.by_index_raw(index)?.name()?.into_owned()); } Ok(names) } diff --git a/crates/bookforge-epub/tests/hostile_corpus.rs b/crates/bookforge-epub/tests/hostile_corpus.rs index 170103ac..31bfd7fb 100644 --- a/crates/bookforge-epub/tests/hostile_corpus.rs +++ b/crates/bookforge-epub/tests/hostile_corpus.rs @@ -20,7 +20,9 @@ use std::{ use bookforge_core::{config::BilingualMode, segment::BlockTranslation}; use bookforge_epub::{ RebuildOptions, - archive_limits::{ArchiveLimits, read_archive_text, validate_archive_metadata}, + archive_limits::{ + ArchiveLimits, ArchiveReadBudget, read_archive_text, validate_archive_metadata, + }, inspect_epub, read_epub, rebuild_epub, rebuild_epub_with_options, text_coverage, }; use quick_xml::{Reader, events::Event}; @@ -236,8 +238,9 @@ fn injectable_limits_catch_declared_size_and_ratio_lies() { fn bounded_read_catches_declared_sizes_that_lie_about_expansion() { let dir = tmp_dir("bomb-read"); let epub = dir.join("lie.epub"); - // Stored payload expands far beyond what the central directory claims; - // with an injected 1 KiB cap the bounded read stops after 1 KiB + 1. + // Stored payload expands far beyond what the central directory claims. + // zip refuses to inflate past the declared 16 bytes, before the injected + // 1 KiB cap; the rejection must still name the entry. let big = vec![b'x'; 64 * 1024]; write_deflated_fixture(&dir.join("liar-src.zip"), "liar.bin", &big); relabel_declared_sizes(&dir.join("liar-src.zip"), &epub); @@ -255,9 +258,11 @@ fn bounded_read_catches_declared_sizes_that_lie_about_expansion() { let error = budget .read_entry(&mut file, "liar.bin", compressed) .expect_err("actual 64 KiB expansion must be caught"); + let message = error.to_string(); assert!( - error.to_string().contains("expanded data exceeds"), - "unexpected error: {error}" + message.contains("EPUB decompression failed for 'liar.bin'") + && message.contains("declared uncompressed size"), + "unexpected error: {message}" ); } @@ -293,10 +298,11 @@ fn relabel_declared_sizes(source: &Path, target: &Path) { #[test] fn bounded_budget_tracks_total_expansion_across_entries() { - // Two deflated entries whose central declarations say 16 bytes but that - // actually expand to 48 KiB each. The metadata stage passes (declared - // totals fit the tiny caps), so the running total is enforced at READ - // time: entry one fits, entry two crosses the remaining budget. + // Two deflated entries that expand to 48 KiB each against an 80 KiB + // running total, enforced at READ time: entry one fits, entry two crosses + // the remaining budget. zip rejects entries that inflate past their + // declared size, so these are honest and the budget is built directly; + // validate_archive_metadata would reject the declared 96 KiB upfront. let half: Vec = vec![b'z'; 48 * 1024]; let dir = tmp_dir("bomb-total"); let source = dir.join("half-src.zip"); @@ -308,10 +314,8 @@ fn bounded_budget_tracks_total_expansion_across_entries() { zip.start_file("b.bin", deflated).expect("start"); zip.write_all(&half).expect("write"); zip.finish().expect("finish"); - let epub = dir.join("liars.epub"); - relabel_declared_sizes(&source, &epub); - let mut archive = ZipArchive::new(File::open(&epub).expect("open")).expect("zip"); + let mut archive = ZipArchive::new(File::open(&source).expect("open")).expect("zip"); let limits = ArchiveLimits { max_entry_uncompressed_size: 64 * 1024, max_total_uncompressed_size: 80 * 1024, @@ -321,8 +325,7 @@ fn bounded_budget_tracks_total_expansion_across_entries() { ..tiny_limits(true) } }; - let mut budget = - validate_archive_metadata(&mut archive, limits).expect("16-byte declarations fit"); + let mut budget = ArchiveReadBudget::new(limits); for name in ["a.bin", "b.bin"] { let mut file = archive.by_name(name).expect("entry exists"); @@ -1215,7 +1218,7 @@ fn xml_string_api_preserves_unicode_entities_namespaces_and_archive_members() { assert_eq!(entry_bytes(&output, "mimetype"), MIMETYPE); let mut archive = ZipArchive::new(File::open(&output).unwrap()).unwrap(); let first = archive.by_index(0).unwrap(); - assert_eq!(first.name(), "mimetype"); + assert_eq!(first.name().unwrap(), "mimetype"); assert_eq!(first.compression(), CompressionMethod::Stored); } diff --git a/crates/bookforge-epub/tests/inspect.rs b/crates/bookforge-epub/tests/inspect.rs index e64d5966..5889dcb2 100644 --- a/crates/bookforge-epub/tests/inspect.rs +++ b/crates/bookforge-epub/tests/inspect.rs @@ -126,6 +126,50 @@ fn rebuilds_epub_with_patched_xhtml_and_preserved_resources() { assert!(chapter.contains("Ciao da un EPUB minimo.")); } +#[test] +fn non_ascii_entry_names_resolve_through_inspect_read_and_rebuild() { + // zip 9 decodes entry names on access instead of at parse time; a UTF-8 + // name must still match its manifest href on every archive path. + let href = "capítulo-東京.xhtml"; + let fixture = + create_epub_fixture_with_chapter("non-ascii-name", href, "

Hello from chapter 1.

"); + + let inspection = inspect_epub(&fixture).expect("non-ASCII spine entry should be found"); + assert_eq!(inspection.xhtml_spine_count, 1); + + let book = read_epub(&fixture).expect("non-ASCII entry should parse into IR"); + let body_block = book + .blocks + .iter() + .find(|block| block_text(block) == "Hello from chapter 1.") + .expect("body paragraph should be extracted"); + + let output = std::env::temp_dir().join(format!( + "bookforge-rebuilt-non-ascii-{}.epub", + std::process::id() + )); + let _ = std::fs::remove_file(&output); + rebuild_epub( + &book, + &[BlockTranslation { + block_id: body_block.id.clone(), + text: "Ciao da un capitolo non ASCII.".to_string(), + }], + &output, + ) + .expect("EPUB with a non-ASCII entry should rebuild"); + + let mut archive = ZipArchive::new(File::open(&output).expect("rebuilt EPUB should exist")) + .expect("rebuilt EPUB should be a zip"); + let mut chapter = String::new(); + archive + .by_name(&format!("OEBPS/{href}")) + .expect("non-ASCII entry name should survive the rebuild") + .read_to_string(&mut chapter) + .expect("chapter should be UTF-8"); + assert!(chapter.contains("Ciao da un capitolo non ASCII.")); +} + #[test] fn packed_notes_extract_and_rebuild_at_matching_dom_paths() { let fixture = create_epub_fixture( @@ -434,6 +478,10 @@ fn block_text(block: &bookforge_core::ir::Block) -> String { } fn create_epub_fixture(name: &str, body: &str) -> PathBuf { + create_epub_fixture_with_chapter(name, "chapter1.xhtml", body) +} + +fn create_epub_fixture_with_chapter(name: &str, chapter_href: &str, body: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("clock should be after epoch") @@ -463,16 +511,20 @@ fn create_epub_fixture(name: &str, body: &str) -> PathBuf { .start_file("OEBPS/content.opf", deflated) .expect("package entry should start"); writer - .write_all(CONTENT_OPF.as_bytes()) + .write_all( + CONTENT_OPF + .replace("chapter1.xhtml", chapter_href) + .as_bytes(), + ) .expect("package should write"); writer .start_file("OEBPS/nav.xhtml", deflated) .expect("nav entry should start"); writer - .write_all(NAV_XHTML.as_bytes()) + .write_all(NAV_XHTML.replace("chapter1.xhtml", chapter_href).as_bytes()) .expect("nav should write"); writer - .start_file("OEBPS/chapter1.xhtml", deflated) + .start_file(format!("OEBPS/{chapter_href}"), deflated) .expect("chapter entry should start"); writer .write_all(chapter_xhtml(body).as_bytes()) diff --git a/crates/bookforge-llm/src/concurrency.rs b/crates/bookforge-llm/src/concurrency.rs index b15816a2..5cb53750 100644 --- a/crates/bookforge-llm/src/concurrency.rs +++ b/crates/bookforge-llm/src/concurrency.rs @@ -120,12 +120,10 @@ impl Drop for AdaptivePermit { // Atomically claim a burn slot iff one is pending. If none is // pending, fetch_update returns Err and we fall through to the // normal Drop, which releases the permit back into the pool. - let claimed = self - .permits_to_burn - .fetch_update(Ordering::AcqRel, Ordering::Acquire, |n| { - if n > 0 { Some(n - 1) } else { None } - }) - .is_ok(); + let claimed = fetch_update(&self.permits_to_burn, |n| { + if n > 0 { Some(n - 1) } else { None } + }) + .is_ok(); if claimed && let Some(permit) = self.permit.take() { permit.forget(); @@ -133,6 +131,19 @@ impl Drop for AdaptivePermit { } } +/// `AtomicUsize::fetch_update` with the orderings every caller here uses. +/// +/// Rust 1.99 deprecates `fetch_update` in favor of `try_update`, but +/// `try_update` is unstable on the 1.88 MSRV, so the deprecated method stays +/// behind this single allow until the MSRV can use the new name. +#[allow(deprecated)] +fn fetch_update( + atomic: &AtomicUsize, + update: impl FnMut(usize) -> Option, +) -> Result { + atomic.fetch_update(Ordering::AcqRel, Ordering::Acquire, update) +} + impl AdaptiveLimiter { pub fn new(min: usize, max: usize) -> Self { Self::new_with_progress(min, max, Duration::from_secs(2), None) @@ -235,18 +246,14 @@ impl AdaptiveLimiter { let mut remaining = new - previous; *state = new; while remaining > 0 { - match self.permits_to_burn.fetch_update( - Ordering::AcqRel, - Ordering::Acquire, - |burn| { - if burn == 0 { - None - } else { - let cancel = burn.min(remaining); - Some(burn - cancel) - } - }, - ) { + match fetch_update(&self.permits_to_burn, |burn| { + if burn == 0 { + None + } else { + let cancel = burn.min(remaining); + Some(burn - cancel) + } + }) { Ok(prev_burn) => { let cancelled = prev_burn.min(remaining); remaining -= cancelled;