From 4bd588bce728596d838724ac0cd6d0bb7acf3aba Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 10 Sep 2026 23:05:38 -0700 Subject: [PATCH] test: fuzz page-model edits Add a mutation driver over the page model (text replacement, character formatting, paragraph insertion at nesting boundaries, subtree deletion and reordering, collapse, outline geometry and outline insertion) that publishes each mutated model through the page writer and checks that the written page reads back as the model, that an unchanged model publishes nothing, and that objects outside the model keep their bytes. Rejections are allowed only as explicit writer errors. A deterministic test runs four seeds and 400 generated inputs; the page_model fuzz target runs the same driver. Assisted-by: claude-fable-5.1 --- crates/onestore/tests/page_model_edits.rs | 31 ++ crates/onestore/tests/support/page_edits.rs | 527 ++++++++++++++++++++ fuzz/Cargo.toml | 7 + fuzz/fuzz_targets/page_model.rs | 7 + 4 files changed, 572 insertions(+) create mode 100644 crates/onestore/tests/page_model_edits.rs create mode 100644 crates/onestore/tests/support/page_edits.rs create mode 100644 fuzz/fuzz_targets/page_model.rs diff --git a/crates/onestore/tests/page_model_edits.rs b/crates/onestore/tests/page_model_edits.rs new file mode 100644 index 0000000000000000000000000000000000000000..b01d1d2564488b8e7545c626e278b588de3cb0fb --- /dev/null +++ b/crates/onestore/tests/page_model_edits.rs @@ -0,0 +1,31 @@ +//! Deterministic seeds for the page-model mutation driver shared with the `page_model` fuzz target. + +#[path = "support/page_edits.rs"] +mod page_edits; + +#[test] +fn random_model_edits_publish_and_read_back() { + for seed in [ + vec![0, 0, 3, 0, 5, b'a', b'b'], + vec![1, 3, 2, 0, 0, 1, 2, 4, 1, 3, 5, 0, 2], + vec![2, 2, 1, 3, 4, 0, 0, 7, 1, 0, 8, 9, 10], + vec![3, 5, 4, 5, 0, 1, 7, 0, 30, 40, 6, 0, 0], + ] { + page_edits::run(&seed); + } + let mut state = 0x2545_f491_4f6c_dd1d_u64; + for _ in 0..400 { + let mut input = Vec::new(); + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + let length = 8 + (state % 40) as usize; + for _ in 0..length { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + input.push((state >> 24) as u8); + } + page_edits::run(&input); + } +} diff --git a/crates/onestore/tests/support/page_edits.rs b/crates/onestore/tests/support/page_edits.rs new file mode 100644 index 0000000000000000000000000000000000000000..d7d7a88ed58ababcbdeb6302fb86f92cf17a8e9b --- /dev/null +++ b/crates/onestore/tests/support/page_edits.rs @@ -0,0 +1,527 @@ +//! Drives random page-model mutations through the page writer and checks its contract: +//! a published model reads back as written, and objects outside the edit keep their bytes. + +use onestore::{ + ExGuid, PreparedEdit, RevisionIndex, Store, + document::{Document, Format, Layout}, + page::{ + Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, + text::{Edit, new_id}, + }, +}; +use std::{collections::BTreeSet, sync::LazyLock}; + +#[path = "current.rs"] +pub(crate) mod current; + +static SOURCES: LazyLock<[Vec; 4]> = LazyLock::new(|| { + [ + onestore::create_section("model.one", "Original 🦀 é 東京", "Author").unwrap(), + include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one").to_vec(), + include_bytes!("../../../../corpus/paragraph-edit/before/notebook/synthetic.one").to_vec(), + include_bytes!("../../../../corpus/outline-edit/tree/before/notebook/synthetic.one") + .to_vec(), + ] +}); + +struct Bytes<'a> { + input: &'a [u8], + at: usize, +} + +impl Bytes<'_> { + fn next(&mut self) -> Option { + let byte = *self.input.get(self.at)?; + self.at += 1; + Some(byte) + } + + fn pick(&mut self, count: usize) -> Option { + if count == 0 { + return None; + } + Some(usize::from(self.next().unwrap_or(0)) % count) + } + + fn text(&mut self) -> String { + let length = usize::from(self.next().unwrap_or(0)) % 12; + let mut text = String::new(); + for _ in 0..length { + text.push(match self.next().unwrap_or(b' ') % 12 { + 0 => '🦀', + 1 => 'é', + 2 => '\u{301}', + 3 => '東', + 4 => ' ', + 5 => '\u{05e9}', + 6 => '\u{000b}', + n => (b'a' + n) as char, + }); + } + text + } +} + +fn model(bytes: &[u8], space: ExGuid) -> Option { + let store = Store::parse(bytes).ok()?; + let index = RevisionIndex::parse(&store).ok()?; + let document = Document::parse(&index).ok()?; + Page::from_space(&document, space).ok() +} + +/// What the writer promises to reproduce exactly; other fields are writer- or reader-owned. +fn projection(page: &Page) -> String { + let mut out = String::new(); + for object in &page.objects { + match object { + PageObject::Outline(outline) => { + // A new outline without a width takes the writer's 468 pt default. + out.push_str(&format!( + "outline {} {:?} {:?} {:?}\n", + outline.id, + outline.layout.x, + outline.layout.y, + outline.layout.max_width.unwrap_or(468.0) + )); + for paragraph in &outline.paragraphs { + out.push_str(&format!( + " {} parent {:?} collapsed {}", + paragraph.id, paragraph.parent, paragraph.collapsed + )); + match ¶graph.content { + ParagraphContent::Text(text) => { + out.push_str(&format!(" text {} {:?}", text.id, text.text.text())); + for span in text.text.spans() { + let f = &span.format; + out.push_str(&format!( + " [{} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?}]", + span.end, + f.bold.unwrap_or(false), + f.italic.unwrap_or(false), + f.underline.unwrap_or(false), + f.strike.unwrap_or(false), + f.superscript.unwrap_or(false), + f.subscript.unwrap_or(false), + f.font, + f.font_size, + f.color, + f.highlight + )); + } + } + ParagraphContent::Table(table) => { + out.push_str(&format!(" table {}", table.id)) + } + ParagraphContent::Unsupported(u) => { + out.push_str(&format!(" unsupported {}", u.id)) + } + } + out.push('\n'); + } + } + PageObject::Title(title) => out.push_str(&format!("title {}\n", title.id)), + PageObject::Image(image) => out.push_str(&format!("image {}\n", image.id)), + PageObject::Unsupported(u) => out.push_str(&format!("unsupported {}\n", u.id)), + } + } + out +} + +fn text_paragraphs(outline: &mut Outline) -> Vec { + outline + .paragraphs + .iter() + .enumerate() + .filter(|(_, p)| p.text().is_some()) + .map(|(i, _)| i) + .collect() +} + +fn fresh_paragraph(template: &PageParagraph, text: String) -> PageParagraph { + let mut paragraph = template.clone(); + paragraph.id = new_id().unwrap(); + paragraph.parent = None; + paragraph.level = 1; + paragraph.lists.clear(); + paragraph.tags.clear(); + paragraph.style = None; + paragraph.collapsed = false; + let format = template + .text() + .map(|t| t.text.format_at(0).unwrap().clone()) + .unwrap_or_default(); + paragraph.content = ParagraphContent::Text(onestore::page::TextObject { + id: new_id().unwrap(), + date_field: None, + text: Paragraph::new(text, format), + tags: Vec::new(), + }); + paragraph +} + +/// Removes a paragraph and every descendant, returning the removed identities. +fn remove_subtree(outline: &mut Outline, index: usize) -> BTreeSet { + let mut removed = BTreeSet::from([outline.paragraphs[index].id]); + loop { + let before = removed.len(); + for paragraph in &outline.paragraphs { + if paragraph.parent.is_some_and(|p| removed.contains(&p)) { + removed.insert(paragraph.id); + } + } + if removed.len() == before { + break; + } + } + outline.paragraphs.retain(|p| !removed.contains(&p.id)); + removed +} + +fn mutate(page: &mut Page, bytes: &mut Bytes<'_>) { + let steps = usize::from(bytes.next().unwrap_or(0)) % 6 + 1; + for _ in 0..steps { + let Some(kind) = bytes.next() else { return }; + let outlines: Vec = page + .objects + .iter() + .enumerate() + .filter(|(_, o)| matches!(o, PageObject::Outline(_))) + .map(|(i, _)| i) + .collect(); + match kind % 9 { + 0 | 1 => { + let Some(o) = bytes.pick(outlines.len()) else { + continue; + }; + let PageObject::Outline(outline) = &mut page.objects[outlines[o]] else { + continue; + }; + let texts = text_paragraphs(outline); + let Some(p) = bytes.pick(texts.len()) else { + continue; + }; + let text = outline.paragraphs[texts[p]].text_mut().unwrap(); + let end = text.text.utf16_offset(text.text.text().len()).unwrap(); + let start = bytes.next().map_or(0, |b| u32::from(b) % (end + 1)); + let stop = bytes + .next() + .map_or(end, |b| start + u32::from(b) % (end - start + 1)); + let format = text.text.format_at(start).cloned().unwrap_or_default(); + let replacement = bytes.text(); + let _ = text.text.apply(Edit { + range: start..stop, + replacement: Paragraph::new(replacement, format), + }); + } + 2 => { + let Some(o) = bytes.pick(outlines.len()) else { + continue; + }; + let PageObject::Outline(outline) = &mut page.objects[outlines[o]] else { + continue; + }; + let texts = text_paragraphs(outline); + let Some(p) = bytes.pick(texts.len()) else { + continue; + }; + let text = outline.paragraphs[texts[p]].text_mut().unwrap(); + let end = text.text.utf16_offset(text.text.text().len()).unwrap(); + let start = bytes.next().map_or(0, |b| u32::from(b) % (end + 1)); + let stop = bytes + .next() + .map_or(end, |b| start + u32::from(b) % (end - start + 1)); + let Ok(mut slice) = text.text.slice(start..stop) else { + continue; + }; + let attribute = bytes.next().unwrap_or(0) % 8; + let runs: Vec<(String, Format)> = { + let mut runs = Vec::new(); + let mut from = 0; + for span in slice.spans() { + let mut format = span.format.clone(); + match attribute { + 0 => format.bold = Some(!format.bold.unwrap_or(false)), + 1 => format.italic = Some(!format.italic.unwrap_or(false)), + 2 => format.underline = Some(!format.underline.unwrap_or(false)), + 3 => format.strike = Some(!format.strike.unwrap_or(false)), + 4 => { + format.font_size = Some(if format.font_size == Some(14.0) { + 11.0 + } else { + 14.0 + }) + } + 5 => { + format.color = Some(if format.color == Some(0x00ff) { + 0xff000000 + } else { + 0x00ff + }) + } + 6 => format.highlight = Some(0x00ffff), + _ => format.font = Some("Consolas".into()), + } + runs.push((slice.text()[from..span.end].to_owned(), format)); + from = span.end; + } + runs + }; + slice = Paragraph::from_runs(runs); + let _ = text.text.apply(Edit { + range: start..stop, + replacement: slice, + }); + } + 3 => { + let Some(o) = bytes.pick(outlines.len()) else { + continue; + }; + let PageObject::Outline(outline) = &mut page.objects[outlines[o]] else { + continue; + }; + let Some(template) = outline + .paragraphs + .iter() + .find(|p| p.text().is_some()) + .cloned() + else { + continue; + }; + let mut fresh = fresh_paragraph(&template, bytes.text()); + // Only subtree boundaries keep the pre-order flattening valid. + let boundaries: Vec = (0..=outline.paragraphs.len()) + .filter(|i| { + outline + .paragraphs + .get(*i) + .is_none_or(|p| p.parent.is_none()) + }) + .collect(); + let at = boundaries[bytes.pick(boundaries.len()).unwrap_or(0)]; + if bytes.next().unwrap_or(0).is_multiple_of(3) + && let Some(parent) = at.checked_sub(1).map(|i| &outline.paragraphs[i]) + { + fresh.parent = Some(parent.id); + fresh.level = parent.level + 1; + } + outline.paragraphs.insert(at, fresh); + } + 4 => { + let Some(o) = bytes.pick(outlines.len()) else { + continue; + }; + let PageObject::Outline(outline) = &mut page.objects[outlines[o]] else { + continue; + }; + let Some(index) = bytes.pick(outline.paragraphs.len()) else { + continue; + }; + remove_subtree(outline, index); + } + 5 => { + let Some(o) = bytes.pick(outlines.len()) else { + continue; + }; + let PageObject::Outline(outline) = &mut page.objects[outlines[o]] else { + continue; + }; + let top: Vec = outline + .paragraphs + .iter() + .enumerate() + .filter(|(_, p)| p.parent.is_none()) + .map(|(i, _)| i) + .collect(); + let (Some(from), Some(to)) = (bytes.pick(top.len()), bytes.pick(top.len())) else { + continue; + }; + let moving = outline.paragraphs[top[from]].clone(); + let mut subtree = remove_subtree(outline, top[from]); + subtree.remove(&moving.id); + let mut descendants: Vec = Vec::new(); + let mut rest = Vec::new(); + for paragraph in outline.paragraphs.drain(..) { + if subtree.contains(¶graph.id) { + descendants.push(paragraph); + } else { + rest.push(paragraph); + } + } + outline.paragraphs = rest; + let anchor = outline + .paragraphs + .iter() + .enumerate() + .filter(|(_, p)| p.parent.is_none()) + .nth(to.min(top.len().saturating_sub(1))) + .map(|(i, _)| i) + .unwrap_or(outline.paragraphs.len()); + let mut block = vec![moving]; + block.extend(descendants); + for (offset, paragraph) in block.into_iter().enumerate() { + outline.paragraphs.insert(anchor + offset, paragraph); + } + } + 6 => { + let Some(o) = bytes.pick(outlines.len()) else { + continue; + }; + let PageObject::Outline(outline) = &mut page.objects[outlines[o]] else { + continue; + }; + let Some(index) = bytes.pick(outline.paragraphs.len()) else { + continue; + }; + outline.paragraphs[index].collapsed ^= true; + } + 7 => { + let Some(o) = bytes.pick(outlines.len()) else { + continue; + }; + let PageObject::Outline(outline) = &mut page.objects[outlines[o]] else { + continue; + }; + if outline.title { + continue; + } + match bytes.next().unwrap_or(0) % 3 { + 0 => { + outline.layout.x = Some(f32::from(bytes.next().unwrap_or(0)) * 1.5); + outline.layout.y = Some(f32::from(bytes.next().unwrap_or(0)) * 2.25); + } + 1 => { + outline.layout.max_width = + Some(36.0 + f32::from(bytes.next().unwrap_or(0)) * 2.0); + outline.layout.width_set_by_user = Some(true); + } + _ => { + outline.layout.max_width = + Some(36.0 + f32::from(bytes.next().unwrap_or(0)) * 2.0); + outline.layout.width_set_by_user = None; + } + } + } + _ => { + let Some(template) = page.objects.iter().find_map(|o| match o { + PageObject::Outline(outline) => outline + .paragraphs + .iter() + .find(|p| p.text().is_some()) + .cloned(), + _ => None, + }) else { + continue; + }; + let outline = Outline { + id: new_id().unwrap(), + title: false, + min_width: None, + layout: Layout { + x: Some(f32::from(bytes.next().unwrap_or(0)) * 1.5), + y: Some(f32::from(bytes.next().unwrap_or(0)) * 2.25), + ..Default::default() + }, + indents: Vec::new(), + paragraphs: vec![fresh_paragraph(&template, bytes.text())], + unsupported: Vec::new(), + }; + // Titles live in the page's structure list and always follow its children. + let children = page + .objects + .iter() + .position(|o| matches!(o, PageObject::Title(_))) + .unwrap_or(page.objects.len()); + let at = bytes.pick(children + 1).unwrap_or(children); + page.objects.insert(at, PageObject::Outline(outline)); + } + } + } +} + +pub fn run(input: &[u8]) { + let mut bytes = Bytes { input, at: 0 }; + let source = &SOURCES[usize::from(bytes.next().unwrap_or(0)) % SOURCES.len()]; + let store = Store::parse(source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let pages = document.pages().unwrap(); + let Some(page_index) = bytes.pick(pages.len()) else { + return; + }; + let (space, page_object) = pages[page_index]; + let Some(mut after) = model(source, space) else { + return; + }; + mutate(&mut after, &mut bytes); + let Ok(edit) = PreparedEdit::page(source, space, &after, "Fuzz author") else { + return; + }; + let written = edit.as_bytes(); + if written == source.as_slice() { + let before = model(source, space).unwrap(); + assert_eq!( + projection(&before), + projection(&after), + "an unchanged model publishes nothing" + ); + return; + } + current::current(written); + let stored = model(written, space).expect("the written page reads back"); + let (stored, expected) = (projection(&stored), projection(&after)); + if stored != expected { + let differences: Vec = stored + .lines() + .zip(expected.lines()) + .filter(|(a, b)| a != b) + .map(|(a, b)| format!("stored: {a}\nexpected: {b}")) + .collect(); + panic!( + "model round trip ({} vs {} lines):\n{}", + stored.lines().count(), + expected.lines().count(), + differences.join("\n") + ); + } + let raw_before = index.resolve_active(space).unwrap(); + let store = Store::parse(written).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let raw_after = index.resolve_active(space).unwrap(); + let mut modeled = BTreeSet::from([page_object]); + for object in &after.objects { + modeled.insert(object.id()); + let outlines: Vec<&Outline> = match object { + PageObject::Outline(outline) => vec![outline], + PageObject::Title(title) => title.outlines.iter().collect(), + _ => Vec::new(), + }; + for outline in outlines { + modeled.insert(outline.id); + for paragraph in &outline.paragraphs { + modeled.insert(paragraph.id); + if let Some(text) = paragraph.text() { + modeled.insert(text.id); + } + } + } + } + // Revision roots carry derived metadata such as the automatic navigation title, and + // outline groups (0x60019) are containers the model flattens but the writers normalize. + let roots: BTreeSet = raw_before.roots.values().copied().collect(); + for (id, object) in &raw_before.objects { + if modeled.contains(id) + || roots.contains(id) + || matches!(object.jcid, 0x12004d | 0x120001 | 0x60019) + { + continue; + } + let Some(after) = raw_after.objects.get(id) else { + panic!("{id} disappeared from the active revision"); + }; + assert_eq!( + (object.jcid, object.data), + (after.jcid, after.data), + "{id} changed outside the model" + ); + } +} diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 00c4de0ddd9894ba72b924aaccf55d65112e33de..702328b67f6bb61b70d2b31a8e824ca2d7a4e9bb 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -136,3 +136,10 @@ path = "fuzz_targets/canvas_editor.rs" test = false doc = false bench = false + +[[bin]] +name = "page_model" +path = "fuzz_targets/page_model.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/page_model.rs b/fuzz/fuzz_targets/page_model.rs new file mode 100644 index 0000000000000000000000000000000000000000..3972ed4f8aa6a72f78e6ad4a989d21c4619bf167 --- /dev/null +++ b/fuzz/fuzz_targets/page_model.rs @@ -0,0 +1,7 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; + +#[path = "../../crates/onestore/tests/support/page_edits.rs"] +mod page_edits; + +fuzz_target!(|input: &[u8]| page_edits::run(input)); -- 2.54.0