Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 0b39e287a4 Fonts: bound font-controlled work in enumeration, outlines and carets
An embedded font could make each lookup do far more than real fonts need:
- enumerating a collection read up to 256 faces times 1024 named instances,
  each then built with its own shaper; now 4096 per file across all faces,
  with a set for the duplicate-style check instead of a scan;
- composite and VARC glyphs could expand one dense outline without limit,
  and the outline cache only counted entries; outlines now stop at 32768
  path elements (drawing nothing past it) and the cache is also bounded by
  the elements it holds;
- the 64-caret limit applied after filtering, so a ligature lookup could
  read every caret record; it now applies to the records read.
2026-11-02 20:38:19 -05:00
6 changed files with 175 additions and 140 deletions
+6 -1
View File
@@ -227,6 +227,9 @@ impl FontDb {
}
}
/// Most caret records read for one ligature glyph.
const MAX_LIGATURE_CARETS: usize = 64;
/// Caret positions inside a ligature glyph from GDEF `LigCaretList`, in font units from the
/// glyph's origin (sorted ascending). Empty when the font has none for `gid`.
pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
@@ -236,9 +239,12 @@ pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
let Ok(cov) = list.coverage() else { return Vec::new() };
let Some(idx) = cov.get(skrifa::GlyphId::new(gid)) else { return Vec::new() };
let Ok(lig) = list.lig_glyphs().get(idx as usize) else { return Vec::new() };
// The limit applies to the records read, not to the carets kept: a font can list thousands of
// records it then has to be read through for every ligature laid out.
let mut out: Vec<i32> = lig
.caret_values()
.iter()
.take(MAX_LIGATURE_CARETS)
.filter_map(|cv| cv.ok())
.filter_map(|cv| match cv {
skrifa::raw::tables::gdef::CaretValue::Format1(f) => Some(f.coordinate() as i32),
@@ -246,7 +252,6 @@ pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
// Format 2 is a contour point index; outlines aren't consulted here.
skrifa::raw::tables::gdef::CaretValue::Format2(_) => None,
})
.take(64)
.collect();
out.sort_unstable();
out
+142 -17
View File
@@ -368,7 +368,7 @@ struct CatalogEntry {
/// Process-wide font database.
pub struct FontDb {
faces: RwLock<Vec<Arc<FontFace>>>,
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
outlines: Mutex<OutlineCache>,
#[cfg(not(target_arch = "wasm32"))]
catalog: RwLock<Vec<CatalogEntry>>,
/// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]).
@@ -439,6 +439,38 @@ const SYSTEM_FALLBACKS: &[&str] = &[
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
const OUTLINE_CACHE_MAX: usize = 50_000;
/// Most path elements one glyph outline may expand to. Composite and `VARC` glyphs can draw a
/// shared outline many times over; a glyph past this draws nothing rather than holding megabytes.
const MAX_OUTLINE_ELEMENTS: usize = 1 << 15;
/// Most path elements the outline cache holds across all glyphs (about 230 MB at most) before it
/// starts over: distinct glyph ids can each expand one dense outline.
const OUTLINE_CACHE_MAX_ELEMENTS: usize = 1 << 22;
/// Glyph outlines by (face, glyph), with the path elements they hold.
#[derive(Default)]
struct OutlineCache {
paths: HashMap<(u32, u32), Arc<BezPath>>,
elements: usize,
}
impl OutlineCache {
fn get(&self, key: &(u32, u32)) -> Option<Arc<BezPath>> {
self.paths.get(key).cloned()
}
fn insert(&mut self, key: (u32, u32), path: Arc<BezPath>) {
let n = path.elements().len();
if let Some(old) = self.paths.remove(&key) {
self.elements = self.elements.saturating_sub(old.elements().len());
}
if self.paths.len() >= OUTLINE_CACHE_MAX || self.elements.saturating_add(n) > OUTLINE_CACHE_MAX_ELEMENTS {
self.paths.clear();
self.elements = 0;
}
self.paths.insert(key, path);
self.elements = self.elements.saturating_add(n);
}
}
fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
@@ -694,6 +726,10 @@ const MAX_COLLECTION_FACES: u32 = 256;
const MAX_COLLECTION_ENTRIES_SCANNED: u32 = 16_384;
/// Maximum named instances read from a single face.
const MAX_NAMED_INSTANCES_PER_FACE: usize = 1024;
/// Maximum faces (named instances counted separately) enumerated from one font file, across all
/// of a collection's faces: the per-collection and per-face limits multiply, and each face found
/// is then built with its own shaper.
const MAX_ENUMERATED_FACES: usize = 4096;
fn distinct_face_indices(entries: impl IntoIterator<Item = (u32, u32)>) -> Vec<u32> {
let mut offsets = std::collections::HashSet::new();
@@ -730,21 +766,30 @@ fn enumerate_faces(data: &[u8]) -> Vec<Found> {
Err(_) => Vec::new(),
};
let mut out = Vec::new();
// Named instances read and plain faces kept, across the whole file.
let mut budget = MAX_ENUMERATED_FACES;
for i in indices {
if budget == 0 {
break;
}
let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue };
let Some((family, style)) = face_names(&f) else { continue };
let axes = f.axes();
let mut named = Vec::new();
let mut seen = std::collections::HashSet::new();
let instances = f.named_instances();
for ni in instances.iter().take(capped_named_instance_count(instances.len())) {
let Some(left) = budget.checked_sub(1) else { break };
budget = left;
let Some(style) = name(&f, &[ni.subfamily_name_id()]) else { continue };
if named.iter().any(|(_, s, _): &(u32, String, _)| s.eq_ignore_ascii_case(&style)) {
if !seen.insert(style.to_ascii_lowercase()) {
continue;
}
let coords: Vec<([u8; 4], f32)> = axes.iter().zip(ni.user_coords()).map(|(a, v)| (a.tag().to_be_bytes(), v)).collect();
named.push((i, style, coords));
}
if named.is_empty() {
budget = budget.saturating_sub(1);
out.push((i, family, style, Vec::new()));
} else {
out.extend(named.into_iter().map(|(i, s, c)| (i, family.clone(), s, c)));
@@ -905,7 +950,7 @@ impl FontDb {
}
Self {
faces: RwLock::new(faces),
outlines: Mutex::new(HashMap::new()),
outlines: Mutex::new(OutlineCache::default()),
#[cfg(not(target_arch = "wasm32"))]
catalog: RwLock::new(Vec::new()),
font_dirs,
@@ -1513,41 +1558,72 @@ impl FontDb {
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
let key = (face.id, gid);
if let Some(p) = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return p.clone();
return p;
}
let mut pen = FlipPen(BezPath::new());
let mut pen = FlipPen::new(MAX_OUTLINE_ELEMENTS);
if let Some(f) = face.skrifa()
&& let Some(g) = f.outline_glyphs().get(GlyphId::new(gid))
{
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen);
}
let p = Arc::new(pen.0);
let mut cache = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if cache.len() >= OUTLINE_CACHE_MAX {
cache.clear();
}
cache.insert(key, p.clone());
let p = Arc::new(pen.finish());
self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key, p.clone());
p
}
}
struct FlipPen(BezPath);
/// Collects a glyph outline, y flipped, up to `limit` path elements. Past the limit it ignores
/// what follows and [`FlipPen::finish`] gives an empty path: a cut-off outline would fill wrongly.
struct FlipPen {
path: BezPath,
limit: usize,
over: bool,
}
impl FlipPen {
fn new(limit: usize) -> Self {
FlipPen { path: BezPath::new(), limit, over: false }
}
/// Room for one more element?
fn room(&mut self) -> bool {
if !self.over && self.path.elements().len() >= self.limit {
self.over = true;
self.path = BezPath::new();
}
!self.over
}
fn finish(self) -> BezPath {
if self.over { BezPath::new() } else { self.path }
}
}
impl OutlinePen for FlipPen {
fn move_to(&mut self, x: f32, y: f32) {
self.0.move_to((x as f64, -y as f64));
if self.room() {
self.path.move_to((x as f64, -y as f64));
}
}
fn line_to(&mut self, x: f32, y: f32) {
self.0.line_to((x as f64, -y as f64));
if self.room() {
self.path.line_to((x as f64, -y as f64));
}
}
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
self.0.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64));
if self.room() {
self.path.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64));
}
}
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
self.0.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64));
if self.room() {
self.path.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64));
}
}
fn close(&mut self) {
self.0.close_path();
if self.room() {
self.path.close_path();
}
}
}
@@ -1575,6 +1651,55 @@ mod font_dir_tests {
assert_eq!(capped_named_instance_count(MAX_NAMED_INSTANCES_PER_FACE + 1), MAX_NAMED_INSTANCES_PER_FACE);
}
#[test]
fn faces_of_one_file_are_bounded_across_the_collection() {
// The per-collection and per-face limits together would allow far more.
assert!((MAX_COLLECTION_FACES as usize) * MAX_NAMED_INSTANCES_PER_FACE > 16 * MAX_ENUMERATED_FACES);
for data in bundled() {
assert!(enumerate_faces(data).len() <= MAX_ENUMERATED_FACES);
}
}
#[test]
fn an_outline_past_the_element_limit_draws_nothing() {
let mut pen = FlipPen::new(8);
pen.move_to(0.0, 0.0);
pen.line_to(1.0, 1.0);
pen.close();
assert_eq!(pen.finish().elements().len(), 3);
// A shared outline drawn over and over (composite and VARC glyphs).
let mut pen = FlipPen::new(8);
for i in 0..1_000_000 {
pen.move_to(0.0, 0.0);
pen.line_to(i as f32, 1.0);
pen.close();
}
assert_eq!(pen.path.elements().len(), 0);
assert!(pen.finish().elements().is_empty());
}
#[test]
fn outline_cache_is_bounded_by_elements() {
let mut path = BezPath::new();
path.move_to((0.0, 0.0));
for i in 1..MAX_OUTLINE_ELEMENTS {
path.line_to((i as f64, 0.0));
}
let path = Arc::new(path);
let mut cache = OutlineCache::default();
let glyphs = 4 * OUTLINE_CACHE_MAX_ELEMENTS / MAX_OUTLINE_ELEMENTS;
for gid in 0..glyphs as u32 {
cache.insert((1, gid), path.clone());
assert!(cache.elements <= OUTLINE_CACHE_MAX_ELEMENTS);
assert_eq!(cache.elements, cache.paths.values().map(|p| p.elements().len()).sum::<usize>());
}
assert!(cache.get(&(1, glyphs as u32 - 1)).is_some());
// Replacing an entry does not count it twice.
let before = cache.elements;
cache.insert((1, glyphs as u32 - 1), path.clone());
assert_eq!(cache.elements, before);
}
#[test]
fn xdg_data_dirs_contribute_their_fonts_folders() {
let home = Path::new("/home/ann");
+9 -9
View File
@@ -85,14 +85,14 @@ fn chart_parts_share_media_byte_and_item_budgets() {
let mut p = fixture(vec![7; 600]);
p.add_media("a.bin", "application/octet-stream", vec![8; 600]).unwrap();
let bytes = save(&p).unwrap();
assert!(load_within(&bytes, Budget::media(1200, 2)).is_ok());
assert!(load_within(&bytes, Budget::media(1199, 2)).is_err(), "chart payloads share the cumulative media budget");
assert!(load_within(&bytes, Budget::media(1200, 1)).is_err(), "chart parts share the media item budget");
assert!(load_within(&bytes, Budget { total: 1200, items: 2 }).is_ok());
assert!(load_within(&bytes, Budget { total: 1199, items: 2 }).is_err(), "chart payloads share the cumulative media budget");
assert!(load_within(&bytes, Budget { total: 1200, items: 1 }).is_err(), "chart parts share the media item budget");
let duplicated = rewrite_manifest(&bytes, |manifest| {
let media = manifest["presentation"]["media"].as_array_mut().unwrap();
media.push(media[0].clone());
});
assert!(load_within(&duplicated, Budget::media(1200, 2)).is_err(), "duplicate media IDs still count as listed items");
assert!(load_within(&duplicated, Budget { total: 1200, items: 2 }).is_err(), "duplicate media IDs still count as listed items");
}
#[test]
@@ -104,9 +104,9 @@ fn legacy_inline_chart_bytes_are_budgeted_and_preserved() {
part.remove("nativeEntry");
part.insert("bytes".into(), json!(vec![9_u8; 600]));
});
assert_eq!(load_within(&legacy, Budget::media(1200, 2)).unwrap().0, p);
assert!(load_within(&legacy, Budget::media(1199, 2)).is_err());
assert!(load_within(&legacy, Budget::media(1200, 1)).is_err());
assert_eq!(load_within(&legacy, Budget { total: 1200, items: 2 }).unwrap().0, p);
assert!(load_within(&legacy, Budget { total: 1199, items: 2 }).is_err());
assert!(load_within(&legacy, Budget { total: 1200, items: 1 }).is_err());
}
#[test]
@@ -207,7 +207,7 @@ fn corrupt_chart_members_stop_before_reusing_the_inflation_budget() {
}
// A failed CRC may already have inflated bytes. Continuing with the same remaining
// budget would let each corrupt member consume that work budget again.
let result = load_within(&bytes, Budget::media(10, 2));
let result = load_within(&bytes, Budget { total: 10, items: 2 });
assert!(result.is_err(), "two corrupt 10-byte members must not reuse a 10-byte budget");
assert!(result.unwrap_err().to_string().contains("chart-parts/0"));
}
@@ -228,7 +228,7 @@ fn duplicate_physical_zip_names_are_indexed_and_inflated_once() {
assert_eq!(replaced, 2);
let z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
assert_eq!(z.file_names().filter(|n| n.starts_with("chart-parts/")).count(), 1);
let (q, damaged) = load_within(&bytes, Budget::media(10, 2)).unwrap();
let (q, damaged) = load_within(&bytes, Budget { total: 10, items: 2 }).unwrap();
assert_eq!(damaged.len(), 1, "the second reference is missing, not inflated again");
let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") };
let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") };
+13 -55
View File
@@ -29,24 +29,12 @@ const MAX_ENTRY: u64 = 2 << 30;
const MAX_TOTAL_MEDIA: u64 = 4 << 30;
/// Media items and chart sidecar parts one file may list.
const MAX_MEDIA_ITEMS: usize = 100_000;
/// Largest `presentation.json` we will inflate. The manifest holds no media bytes, so it is far
/// smaller than a media entry may be; inflating and parsing one is what dominates memory.
const MAX_MANIFEST: u64 = 256 << 20;
/// What one load may inflate.
#[derive(Clone, Copy)]
struct Budget {
total: u64,
items: usize,
manifest: u64,
}
#[cfg(test)]
impl Budget {
/// A media budget with the default manifest limit.
fn media(total: u64, items: usize) -> Self {
Budget { total, items, manifest: MAX_MANIFEST }
}
}
#[derive(Debug, thiserror::Error)]
@@ -119,7 +107,6 @@ pub fn sniff(bytes: &[u8]) -> bool {
bytes.starts_with(b"PK") && [MIMETYPE, LEGACY_MIMETYPE].iter().any(|m| bytes.windows(m.len()).take(200).any(|w| w == m.as_bytes()))
}
#[cfg(test)]
fn read_entry(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Result<Vec<u8>> {
read_entry_max(z, name, MAX_ENTRY)
}
@@ -127,30 +114,13 @@ fn read_entry(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Result<Vec<
/// 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<Cursor<&[u8]>>, name: &str, max: u64) -> Result<Vec<u8>> {
read_entry_counted(z, name, max, &mut 0)
}
/// [`read_entry_max`] within `left` (and [`MAX_ENTRY`]), charging `left` for every byte inflated,
/// including those inflated before a decode or CRC error, so a member that fails late still
/// spends the shared budget it used.
fn read_entry_charged(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str, left: &mut u64) -> Result<Vec<u8>> {
let mut spent = 0;
let out = read_entry_counted(z, name, (*left).min(MAX_ENTRY), &mut spent);
*left = left.saturating_sub(spent);
out
}
/// [`read_entry_max`], adding the bytes inflated to `spent` whether or not the read succeeds.
fn read_entry_counted(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str, max: u64, spent: &mut u64) -> Result<Vec<u8>> {
let f = z.by_name(name).map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?;
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);
let mut f = f.take(max.saturating_add(1));
let read = f.read_to_end(&mut out);
*spent = spent.saturating_add(out.len() as u64);
read.map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?;
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")));
}
@@ -167,15 +137,12 @@ pub fn load(bytes: &[u8]) -> Result<Presentation> {
/// A chart member that is present but fails decoding, CRC or its entry limit refuses the file.
/// Linked media saved without its bytes isn't damage.
pub fn load_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
load_within(bytes, Budget { total: MAX_TOTAL_MEDIA, items: MAX_MEDIA_ITEMS, manifest: MAX_MANIFEST })
load_within(bytes, Budget { total: MAX_TOTAL_MEDIA, items: MAX_MEDIA_ITEMS })
}
fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec<String>)> {
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| FormatError::NotOurs(e.to_string()))?;
if z.index_for_name("presentation.json").is_none() {
return Err(FormatError::NotOurs("no presentation.json".into()));
}
let json = read_entry_max(&mut z, "presentation.json", budget.manifest)?;
let json = read_entry(&mut z, "presentation.json").map_err(|_| FormatError::NotOurs("no presentation.json".into()))?;
let m = parse_manifest(&json)?;
if m.format != "deckcraft" {
return Err(FormatError::NotOurs(m.format));
@@ -197,9 +164,11 @@ fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec<String
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()));
}
// A failed read is charged for what it inflated, so later members can't reuse that budget.
match read_entry_charged(&mut z, &name, &mut left) {
Ok(data) => item.data = Arc::new(data),
match read_entry_max(&mut z, &name, left.min(MAX_ENTRY)) {
Ok(data) => {
left -= data.len() as u64;
item.data = Arc::new(data);
}
Err(_) if linked_only => {}
Err(e) => damaged.push(e.to_string()),
}
@@ -219,11 +188,9 @@ pub fn repair(p: &mut Presentation) {
}
p.fix_next_id();
let first_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default();
// Indexed once: a per-slide scan of every layout is slides × layouts work.
let layouts: std::collections::HashSet<_> = p.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.id)).collect();
let mut seen = std::collections::HashSet::new();
for i in 0..p.slides.len() {
let needs_layout = p.slides.get(i).is_some_and(|s| !layouts.contains(&s.layout));
let needs_layout = p.slides.get(i).is_some_and(|s| p.layout(s.layout).is_none());
let dup = p.slides.get(i).is_some_and(|s| !seen.insert(s.id));
if needs_layout || dup {
// An exhausted id space leaves a duplicate slide as it is: the engine refuses to
@@ -364,20 +331,16 @@ fn failure(src: &[u8]) -> std::result::Result<Manifest, (String, (usize, usize))
/// nearest first, and a patch is kept only if the parse then gets further. (Files saved since
/// [`finite::Finite`] never contain such `null`s; this reads older ones.)
fn parse_manifest(json: &[u8]) -> Result<Manifest> {
// Parse the inflated text as it is: only a file that fails on a `null` number is copied for
// patching (the copy is bounded by `MAX_MANIFEST`).
let mut last = match failure(json) {
let mut src = json.to_vec();
let mut last = match failure(&src) {
Ok(m) => return Ok(m),
Err(f) => f,
};
if !is_null_number(&last.0) {
return Err(FormatError::Damaged(last.0));
}
let mut src = json.to_vec();
let mut budget = MAX_REPARSE_BYTES;
'fixes: for _ in 0..MAX_NULL_FIXES {
let (msg, pos) = &last;
if !is_null_number(msg) {
let number = msg.starts_with("invalid type: null, expected f") || msg.starts_with("invalid type: null, expected a number");
if !number {
break;
}
let end = offset(&src, pos.0, pos.1).unwrap_or(src.len());
@@ -411,11 +374,6 @@ fn parse_manifest(json: &[u8]) -> Result<Manifest> {
Err(FormatError::Damaged(last.0))
}
/// Is this parse error a `null` where a number belongs (a NaN or infinity serde_json wrote)?
fn is_null_number(msg: &str) -> bool {
msg.starts_with("invalid type: null, expected f") || msg.starts_with("invalid type: null, expected a number")
}
/// Byte offset of a 1-based `line`:`column`.
fn offset(src: &[u8], line: usize, column: usize) -> Option<usize> {
let mut start = 0;
+4 -55
View File
@@ -93,60 +93,9 @@ fn media_budget_is_cumulative() {
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::media(1200, 2)).is_ok());
assert!(load_within(&bytes, Budget::media(1199, 2)).is_err());
assert!(load_within(&bytes, Budget::media(1200, 1)).is_err());
}
#[test]
fn corrupt_media_is_charged_for_what_it_inflated() {
let mut p = Presentation::default();
p.add_media("a.png", "image/png", b"PICTURE-AAAAAAAAAAAA".to_vec());
p.add_media("b.png", "image/png", b"PICTURE-BBBBBBBBBBBB".to_vec());
let mut bytes = save(&p).unwrap();
// Corrupt both stored members, so each fails its CRC only after inflating all its bytes.
for tag in [b"PICTURE-AAAA", b"PICTURE-BBBB"] {
let at = bytes.windows(tag.len()).position(|w| w == tag).unwrap();
bytes[at] ^= 0xff;
}
// Each member fits the budget on its own; the second must not reuse what the first spent.
let result = load_within(&bytes, Budget::media(20, 2));
assert!(result.is_err(), "two corrupt 20-byte members must not share a 20-byte budget");
// With room for both, the file opens and reports both.
let (_, damaged) = load_within(&bytes, Budget::media(40, 2)).unwrap();
assert_eq!(damaged.len(), 2, "{damaged:?}");
}
#[test]
fn oversized_manifest_is_refused_before_inflating() {
let bytes = save(&Presentation::default()).unwrap();
let mut z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
let size = z.by_name("presentation.json").unwrap().size();
assert!(load_within(&bytes, Budget { total: 0, items: 0, manifest: size }).is_ok());
let err = load_within(&bytes, Budget { total: 0, items: 0, manifest: size - 1 }).unwrap_err();
assert!(err.to_string().contains("too large"), "{err}");
}
#[test]
fn many_slides_on_missing_layouts_are_repaired() {
let mut p = Presentation::default();
let template = p.slides[0].clone();
let good = template.layout;
p.slides = (0..2000u32)
.map(|i| {
let mut s = (*template).clone();
s.id = deckcraft_model::SlideId(10_000 + i);
if i % 2 == 1 {
s.layout = deckcraft_model::LayoutId(900_000 + i);
}
Arc::new(s)
})
.collect();
p.next_id = 20_000;
repair(&mut p);
assert!(p.validate().is_empty(), "{:?}", p.validate().first());
assert!(p.slides.iter().all(|s| p.layout(s.layout).is_some()));
assert_eq!(p.slides[0].layout, good);
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]
@@ -175,7 +124,7 @@ fn repeated_media_ids_are_read_once() {
}
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::media(1000, 51)).unwrap().0;
let q = load_within(&bytes, Budget { total: 1000, items: 51 }).unwrap().0;
assert_eq!(q.media.len(), 1);
assert_eq!(q.media[0].data.len(), 1000);
// And the deck saves again.
+1 -3
View File
@@ -1112,13 +1112,11 @@ impl Presentation {
pub fn validate(&self) -> Vec<String> {
let mut out = vec![];
let mut seen = std::collections::HashSet::new();
// Indexed once: a per-slide scan of every layout is slides × layouts work.
let layouts: std::collections::HashSet<_> = self.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.id)).collect();
for s in &self.slides {
if !seen.insert(s.id) {
out.push(format!("duplicate slide id {}", s.id));
}
if !layouts.contains(&s.layout) {
if self.layout(s.layout).is_none() {
out.push(format!("slide {} uses missing layout {}", s.id, s.layout));
}
let mut ids = std::collections::HashSet::new();