diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 1b37a232..d70303fb 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -18,6 +18,17 @@ pub const EXTENSION: &str = "deckcraft"; pub const VERSION: u32 = 1; /// Largest entry we will inflate (guards against zip bombs). const MAX_ENTRY: u64 = 2 << 30; +/// All embedded media together, inflated (#393). +const MAX_TOTAL_MEDIA: u64 = 4 << 30; +/// Media items one file may list. +const MAX_MEDIA_ITEMS: usize = 100_000; + +/// What one load may inflate. +#[derive(Clone, Copy)] +struct Budget { + total: u64, + items: usize, +} #[derive(Debug, thiserror::Error)] pub enum FormatError { @@ -87,17 +98,31 @@ pub fn sniff(bytes: &[u8]) -> bool { } fn read_entry(z: &mut zip::ZipArchive>, name: &str) -> Result> { + read_entry_max(z, name, MAX_ENTRY) +} + +/// Read one member, refusing it before inflating if it declares more than `max` bytes, and +/// stopping at `max` if it inflates further than it declared. +fn read_entry_max(z: &mut zip::ZipArchive>, name: &str, max: u64) -> Result> { let f = z.by_name(name).map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?; - if f.size() > MAX_ENTRY { + if f.size() > max { return Err(FormatError::Damaged(format!("{name} is too large"))); } let mut out = Vec::with_capacity(f.size().min(64 << 20) as usize); - f.take(MAX_ENTRY).read_to_end(&mut out).map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?; + let mut f = f.take(max.saturating_add(1)); + f.read_to_end(&mut out).map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?; + if out.len() as u64 > max { + return Err(FormatError::Damaged(format!("{name} is too large"))); + } Ok(out) } /// Read a presentation. pub fn load(bytes: &[u8]) -> Result { + load_within(bytes, Budget { total: MAX_TOTAL_MEDIA, items: MAX_MEDIA_ITEMS }) +} + +fn load_within(bytes: &[u8], budget: Budget) -> Result { let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| FormatError::NotOurs(e.to_string()))?; let json = read_entry(&mut z, "presentation.json").map_err(|_| FormatError::NotOurs("no presentation.json".into()))?; let m: Manifest = serde_json::from_slice(&json).map_err(|e| FormatError::Damaged(e.to_string()))?; @@ -105,8 +130,21 @@ pub fn load(bytes: &[u8]) -> Result { return Err(FormatError::NotOurs(m.format)); } let mut p = m.presentation; + if p.media.len() > budget.items { + return Err(FormatError::Damaged(format!("{} media items (at most {})", p.media.len(), budget.items))); + } + // A repeated id names the same member: keep the first item, so the member is inflated once + // and the deck can be saved again (a zip can't hold two `media/`). + let mut seen = std::collections::HashSet::new(); + p.media.retain(|m| seen.insert(m.id)); + let mut left = budget.total; for item in &mut p.media { - if let Ok(data) = read_entry(&mut z, &format!("media/{}", item.id.0)) { + let name = format!("media/{}", item.id.0); + if z.by_name(&name).is_ok_and(|f| f.size() > left) { + return Err(FormatError::Damaged("embedded media is larger than DeckCraft can open".into())); + } + if let Ok(data) = read_entry_max(&mut z, &name, left.min(MAX_ENTRY)) { + left -= data.len() as u64; item.data = Arc::new(data); } } @@ -218,6 +256,51 @@ pub fn slides_to_outline(p: &Presentation) -> String { mod tests { use super::*; + #[test] + fn media_budget_is_cumulative() { + let mut p = Presentation::default(); + p.add_media("a.bin", "application/octet-stream", vec![1; 600]); + p.add_media("b.bin", "application/octet-stream", vec![2; 600]); + let bytes = save(&p).unwrap(); + // Exactly at the limit opens; one byte under is refused, though each item fits. + assert!(load_within(&bytes, Budget { total: 1200, items: 2 }).is_ok()); + assert!(load_within(&bytes, Budget { total: 1199, items: 2 }).is_err()); + assert!(load_within(&bytes, Budget { total: 1200, items: 1 }).is_err()); + } + + #[test] + fn repeated_media_ids_are_read_once() { + use std::io::Write; + let mut p = Presentation::default(); + p.add_media("a.bin", "application/octet-stream", vec![7; 1000]); + let good = save(&p).unwrap(); + // The same file with its media item listed 51 times. + let mut z = zip::ZipArchive::new(Cursor::new(good.as_slice())).unwrap(); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + for i in 0..z.len() { + let mut f = z.by_index(i).unwrap(); + if f.name() == "presentation.json" { + let mut json = vec![]; + f.read_to_end(&mut json).unwrap(); + let mut v: serde_json::Value = serde_json::from_slice(&json).unwrap(); + let media = &mut v["presentation"]["media"]; + let one = media[0].clone(); + *media = serde_json::Value::Array(vec![one; 51]); + out.start_file("presentation.json", zip::write::SimpleFileOptions::default()).unwrap(); + out.write_all(&serde_json::to_vec(&v).unwrap()).unwrap(); + } else { + out.raw_copy_file(f).unwrap(); + } + } + let bytes = out.finish().unwrap().into_inner(); + // 51 references to one 1000-byte member fit a 1000-byte budget: it's inflated once. + let q = load_within(&bytes, Budget { total: 1000, items: 51 }).unwrap(); + assert_eq!(q.media.len(), 1); + assert_eq!(q.media[0].data.len(), 1000); + // And the deck saves again. + assert!(save(&q).is_ok()); + } + #[test] fn roundtrip_with_media() { let mut p = Presentation::default();