authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-10 23:05:38-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-10 23:05:38-07:00
log4bd588bce728596d838724ac0cd6d0bb7acf3aba
treeec94b54602ff45f4845804d2e4ca7f0617f5aef5
parent3283203e9f87d21af0fe6dd1f6aaa18866cbc25e
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

4 files changed, 572 insertions(+), 0 deletions(-)

crates/onestore/tests/page_model_edits.rs created+31
......@@ -0,0 +1,31 @@
1//! Deterministic seeds for the page-model mutation driver shared with the `page_model` fuzz target.
2
3#[path = "support/page_edits.rs"]
4mod page_edits;
5
6#[test]
7fn random_model_edits_publish_and_read_back() {
8 for seed in [
9 vec![0, 0, 3, 0, 5, b'a', b'b'],
10 vec![1, 3, 2, 0, 0, 1, 2, 4, 1, 3, 5, 0, 2],
11 vec![2, 2, 1, 3, 4, 0, 0, 7, 1, 0, 8, 9, 10],
12 vec![3, 5, 4, 5, 0, 1, 7, 0, 30, 40, 6, 0, 0],
13 ] {
14 page_edits::run(&seed);
15 }
16 let mut state = 0x2545_f491_4f6c_dd1d_u64;
17 for _ in 0..400 {
18 let mut input = Vec::new();
19 state ^= state << 13;
20 state ^= state >> 7;
21 state ^= state << 17;
22 let length = 8 + (state % 40) as usize;
23 for _ in 0..length {
24 state ^= state << 13;
25 state ^= state >> 7;
26 state ^= state << 17;
27 input.push((state >> 24) as u8);
28 }
29 page_edits::run(&input);
30 }
31}
crates/onestore/tests/support/page_edits.rs created+527
......@@ -0,0 +1,527 @@
1//! Drives random page-model mutations through the page writer and checks its contract:
2//! a published model reads back as written, and objects outside the edit keep their bytes.
3
4use onestore::{
5 ExGuid, PreparedEdit, RevisionIndex, Store,
6 document::{Document, Format, Layout},
7 page::{
8 Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent,
9 text::{Edit, new_id},
10 },
11};
12use std::{collections::BTreeSet, sync::LazyLock};
13
14#[path = "current.rs"]
15pub(crate) mod current;
16
17static SOURCES: LazyLock<[Vec<u8>; 4]> = LazyLock::new(|| {
18 [
19 onestore::create_section("model.one", "Original 🦀 é 東京", "Author").unwrap(),
20 include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one").to_vec(),
21 include_bytes!("../../../../corpus/paragraph-edit/before/notebook/synthetic.one").to_vec(),
22 include_bytes!("../../../../corpus/outline-edit/tree/before/notebook/synthetic.one")
23 .to_vec(),
24 ]
25});
26
27struct Bytes<'a> {
28 input: &'a [u8],
29 at: usize,
30}
31
32impl Bytes<'_> {
33 fn next(&mut self) -> Option<u8> {
34 let byte = *self.input.get(self.at)?;
35 self.at += 1;
36 Some(byte)
37 }
38
39 fn pick(&mut self, count: usize) -> Option<usize> {
40 if count == 0 {
41 return None;
42 }
43 Some(usize::from(self.next().unwrap_or(0)) % count)
44 }
45
46 fn text(&mut self) -> String {
47 let length = usize::from(self.next().unwrap_or(0)) % 12;
48 let mut text = String::new();
49 for _ in 0..length {
50 text.push(match self.next().unwrap_or(b' ') % 12 {
51 0 => '🦀',
52 1 => 'é',
53 2 => '\u{301}',
54 3 => '東',
55 4 => ' ',
56 5 => '\u{05e9}',
57 6 => '\u{000b}',
58 n => (b'a' + n) as char,
59 });
60 }
61 text
62 }
63}
64
65fn model(bytes: &[u8], space: ExGuid) -> Option<Page> {
66 let store = Store::parse(bytes).ok()?;
67 let index = RevisionIndex::parse(&store).ok()?;
68 let document = Document::parse(&index).ok()?;
69 Page::from_space(&document, space).ok()
70}
71
72/// What the writer promises to reproduce exactly; other fields are writer- or reader-owned.
73fn projection(page: &Page) -> String {
74 let mut out = String::new();
75 for object in &page.objects {
76 match object {
77 PageObject::Outline(outline) => {
78 // A new outline without a width takes the writer's 468 pt default.
79 out.push_str(&format!(
80 "outline {} {:?} {:?} {:?}\n",
81 outline.id,
82 outline.layout.x,
83 outline.layout.y,
84 outline.layout.max_width.unwrap_or(468.0)
85 ));
86 for paragraph in &outline.paragraphs {
87 out.push_str(&format!(
88 " {} parent {:?} collapsed {}",
89 paragraph.id, paragraph.parent, paragraph.collapsed
90 ));
91 match &paragraph.content {
92 ParagraphContent::Text(text) => {
93 out.push_str(&format!(" text {} {:?}", text.id, text.text.text()));
94 for span in text.text.spans() {
95 let f = &span.format;
96 out.push_str(&format!(
97 " [{} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?}]",
98 span.end,
99 f.bold.unwrap_or(false),
100 f.italic.unwrap_or(false),
101 f.underline.unwrap_or(false),
102 f.strike.unwrap_or(false),
103 f.superscript.unwrap_or(false),
104 f.subscript.unwrap_or(false),
105 f.font,
106 f.font_size,
107 f.color,
108 f.highlight
109 ));
110 }
111 }
112 ParagraphContent::Table(table) => {
113 out.push_str(&format!(" table {}", table.id))
114 }
115 ParagraphContent::Unsupported(u) => {
116 out.push_str(&format!(" unsupported {}", u.id))
117 }
118 }
119 out.push('\n');
120 }
121 }
122 PageObject::Title(title) => out.push_str(&format!("title {}\n", title.id)),
123 PageObject::Image(image) => out.push_str(&format!("image {}\n", image.id)),
124 PageObject::Unsupported(u) => out.push_str(&format!("unsupported {}\n", u.id)),
125 }
126 }
127 out
128}
129
130fn text_paragraphs(outline: &mut Outline) -> Vec<usize> {
131 outline
132 .paragraphs
133 .iter()
134 .enumerate()
135 .filter(|(_, p)| p.text().is_some())
136 .map(|(i, _)| i)
137 .collect()
138}
139
140fn fresh_paragraph(template: &PageParagraph, text: String) -> PageParagraph {
141 let mut paragraph = template.clone();
142 paragraph.id = new_id().unwrap();
143 paragraph.parent = None;
144 paragraph.level = 1;
145 paragraph.lists.clear();
146 paragraph.tags.clear();
147 paragraph.style = None;
148 paragraph.collapsed = false;
149 let format = template
150 .text()
151 .map(|t| t.text.format_at(0).unwrap().clone())
152 .unwrap_or_default();
153 paragraph.content = ParagraphContent::Text(onestore::page::TextObject {
154 id: new_id().unwrap(),
155 date_field: None,
156 text: Paragraph::new(text, format),
157 tags: Vec::new(),
158 });
159 paragraph
160}
161
162/// Removes a paragraph and every descendant, returning the removed identities.
163fn remove_subtree(outline: &mut Outline, index: usize) -> BTreeSet<ExGuid> {
164 let mut removed = BTreeSet::from([outline.paragraphs[index].id]);
165 loop {
166 let before = removed.len();
167 for paragraph in &outline.paragraphs {
168 if paragraph.parent.is_some_and(|p| removed.contains(&p)) {
169 removed.insert(paragraph.id);
170 }
171 }
172 if removed.len() == before {
173 break;
174 }
175 }
176 outline.paragraphs.retain(|p| !removed.contains(&p.id));
177 removed
178}
179
180fn mutate(page: &mut Page, bytes: &mut Bytes<'_>) {
181 let steps = usize::from(bytes.next().unwrap_or(0)) % 6 + 1;
182 for _ in 0..steps {
183 let Some(kind) = bytes.next() else { return };
184 let outlines: Vec<usize> = page
185 .objects
186 .iter()
187 .enumerate()
188 .filter(|(_, o)| matches!(o, PageObject::Outline(_)))
189 .map(|(i, _)| i)
190 .collect();
191 match kind % 9 {
192 0 | 1 => {
193 let Some(o) = bytes.pick(outlines.len()) else {
194 continue;
195 };
196 let PageObject::Outline(outline) = &mut page.objects[outlines[o]] else {
197 continue;
198 };
199 let texts = text_paragraphs(outline);
200 let Some(p) = bytes.pick(texts.len()) else {
201 continue;
202 };
203 let text = outline.paragraphs[texts[p]].text_mut().unwrap();
204 let end = text.text.utf16_offset(text.text.text().len()).unwrap();
205 let start = bytes.next().map_or(0, |b| u32::from(b) % (end + 1));
206 let stop = bytes
207 .next()
208 .map_or(end, |b| start + u32::from(b) % (end - start + 1));
209 let format = text.text.format_at(start).cloned().unwrap_or_default();
210 let replacement = bytes.text();
211 let _ = text.text.apply(Edit {
212 range: start..stop,
213 replacement: Paragraph::new(replacement, format),
214 });
215 }
216 2 => {
217 let Some(o) = bytes.pick(outlines.len()) else {
218 continue;
219 };
220 let PageObject::Outline(outline) = &mut page.objects[outlines[o]] else {
221 continue;
222 };
223 let texts = text_paragraphs(outline);
224 let Some(p) = bytes.pick(texts.len()) else {
225 continue;
226 };
227 let text = outline.paragraphs[texts[p]].text_mut().unwrap();
228 let end = text.text.utf16_offset(text.text.text().len()).unwrap();
229 let start = bytes.next().map_or(0, |b| u32::from(b) % (end + 1));
230 let stop = bytes
231 .next()
232 .map_or(end, |b| start + u32::from(b) % (end - start + 1));
233 let Ok(mut slice) = text.text.slice(start..stop) else {
234 continue;
235 };
236 let attribute = bytes.next().unwrap_or(0) % 8;
237 let runs: Vec<(String, Format)> = {
238 let mut runs = Vec::new();
239 let mut from = 0;
240 for span in slice.spans() {
241 let mut format = span.format.clone();
242 match attribute {
243 0 => format.bold = Some(!format.bold.unwrap_or(false)),
244 1 => format.italic = Some(!format.italic.unwrap_or(false)),
245 2 => format.underline = Some(!format.underline.unwrap_or(false)),
246 3 => format.strike = Some(!format.strike.unwrap_or(false)),
247 4 => {
248 format.font_size = Some(if format.font_size == Some(14.0) {
249 11.0
250 } else {
251 14.0
252 })
253 }
254 5 => {
255 format.color = Some(if format.color == Some(0x00ff) {
256 0xff000000
257 } else {
258 0x00ff
259 })
260 }
261 6 => format.highlight = Some(0x00ffff),
262 _ => format.font = Some("Consolas".into()),
263 }
264 runs.push((slice.text()[from..span.end].to_owned(), format));
265 from = span.end;
266 }
267 runs
268 };
269 slice = Paragraph::from_runs(runs);
270 let _ = text.text.apply(Edit {
271 range: start..stop,
272 replacement: slice,
273 });
274 }
275 3 => {
276 let Some(o) = bytes.pick(outlines.len()) else {
277 continue;
278 };
279 let PageObject::Outline(outline) = &mut page.objects[outlines[o]] else {
280 continue;
281 };
282 let Some(template) = outline
283 .paragraphs
284 .iter()
285 .find(|p| p.text().is_some())
286 .cloned()
287 else {
288 continue;
289 };
290 let mut fresh = fresh_paragraph(&template, bytes.text());
291 // Only subtree boundaries keep the pre-order flattening valid.
292 let boundaries: Vec<usize> = (0..=outline.paragraphs.len())
293 .filter(|i| {
294 outline
295 .paragraphs
296 .get(*i)
297 .is_none_or(|p| p.parent.is_none())
298 })
299 .collect();
300 let at = boundaries[bytes.pick(boundaries.len()).unwrap_or(0)];
301 if bytes.next().unwrap_or(0).is_multiple_of(3)
302 && let Some(parent) = at.checked_sub(1).map(|i| &outline.paragraphs[i])
303 {
304 fresh.parent = Some(parent.id);
305 fresh.level = parent.level + 1;
306 }
307 outline.paragraphs.insert(at, fresh);
308 }
309 4 => {
310 let Some(o) = bytes.pick(outlines.len()) else {
311 continue;
312 };
313 let PageObject::Outline(outline) = &mut page.objects[outlines[o]] else {
314 continue;
315 };
316 let Some(index) = bytes.pick(outline.paragraphs.len()) else {
317 continue;
318 };
319 remove_subtree(outline, index);
320 }
321 5 => {
322 let Some(o) = bytes.pick(outlines.len()) else {
323 continue;
324 };
325 let PageObject::Outline(outline) = &mut page.objects[outlines[o]] else {
326 continue;
327 };
328 let top: Vec<usize> = outline
329 .paragraphs
330 .iter()
331 .enumerate()
332 .filter(|(_, p)| p.parent.is_none())
333 .map(|(i, _)| i)
334 .collect();
335 let (Some(from), Some(to)) = (bytes.pick(top.len()), bytes.pick(top.len())) else {
336 continue;
337 };
338 let moving = outline.paragraphs[top[from]].clone();
339 let mut subtree = remove_subtree(outline, top[from]);
340 subtree.remove(&moving.id);
341 let mut descendants: Vec<PageParagraph> = Vec::new();
342 let mut rest = Vec::new();
343 for paragraph in outline.paragraphs.drain(..) {
344 if subtree.contains(&paragraph.id) {
345 descendants.push(paragraph);
346 } else {
347 rest.push(paragraph);
348 }
349 }
350 outline.paragraphs = rest;
351 let anchor = outline
352 .paragraphs
353 .iter()
354 .enumerate()
355 .filter(|(_, p)| p.parent.is_none())
356 .nth(to.min(top.len().saturating_sub(1)))
357 .map(|(i, _)| i)
358 .unwrap_or(outline.paragraphs.len());
359 let mut block = vec![moving];
360 block.extend(descendants);
361 for (offset, paragraph) in block.into_iter().enumerate() {
362 outline.paragraphs.insert(anchor + offset, paragraph);
363 }
364 }
365 6 => {
366 let Some(o) = bytes.pick(outlines.len()) else {
367 continue;
368 };
369 let PageObject::Outline(outline) = &mut page.objects[outlines[o]] else {
370 continue;
371 };
372 let Some(index) = bytes.pick(outline.paragraphs.len()) else {
373 continue;
374 };
375 outline.paragraphs[index].collapsed ^= true;
376 }
377 7 => {
378 let Some(o) = bytes.pick(outlines.len()) else {
379 continue;
380 };
381 let PageObject::Outline(outline) = &mut page.objects[outlines[o]] else {
382 continue;
383 };
384 if outline.title {
385 continue;
386 }
387 match bytes.next().unwrap_or(0) % 3 {
388 0 => {
389 outline.layout.x = Some(f32::from(bytes.next().unwrap_or(0)) * 1.5);
390 outline.layout.y = Some(f32::from(bytes.next().unwrap_or(0)) * 2.25);
391 }
392 1 => {
393 outline.layout.max_width =
394 Some(36.0 + f32::from(bytes.next().unwrap_or(0)) * 2.0);
395 outline.layout.width_set_by_user = Some(true);
396 }
397 _ => {
398 outline.layout.max_width =
399 Some(36.0 + f32::from(bytes.next().unwrap_or(0)) * 2.0);
400 outline.layout.width_set_by_user = None;
401 }
402 }
403 }
404 _ => {
405 let Some(template) = page.objects.iter().find_map(|o| match o {
406 PageObject::Outline(outline) => outline
407 .paragraphs
408 .iter()
409 .find(|p| p.text().is_some())
410 .cloned(),
411 _ => None,
412 }) else {
413 continue;
414 };
415 let outline = Outline {
416 id: new_id().unwrap(),
417 title: false,
418 min_width: None,
419 layout: Layout {
420 x: Some(f32::from(bytes.next().unwrap_or(0)) * 1.5),
421 y: Some(f32::from(bytes.next().unwrap_or(0)) * 2.25),
422 ..Default::default()
423 },
424 indents: Vec::new(),
425 paragraphs: vec![fresh_paragraph(&template, bytes.text())],
426 unsupported: Vec::new(),
427 };
428 // Titles live in the page's structure list and always follow its children.
429 let children = page
430 .objects
431 .iter()
432 .position(|o| matches!(o, PageObject::Title(_)))
433 .unwrap_or(page.objects.len());
434 let at = bytes.pick(children + 1).unwrap_or(children);
435 page.objects.insert(at, PageObject::Outline(outline));
436 }
437 }
438 }
439}
440
441pub fn run(input: &[u8]) {
442 let mut bytes = Bytes { input, at: 0 };
443 let source = &SOURCES[usize::from(bytes.next().unwrap_or(0)) % SOURCES.len()];
444 let store = Store::parse(source).unwrap();
445 let index = RevisionIndex::parse(&store).unwrap();
446 let document = Document::parse(&index).unwrap();
447 let pages = document.pages().unwrap();
448 let Some(page_index) = bytes.pick(pages.len()) else {
449 return;
450 };
451 let (space, page_object) = pages[page_index];
452 let Some(mut after) = model(source, space) else {
453 return;
454 };
455 mutate(&mut after, &mut bytes);
456 let Ok(edit) = PreparedEdit::page(source, space, &after, "Fuzz author") else {
457 return;
458 };
459 let written = edit.as_bytes();
460 if written == source.as_slice() {
461 let before = model(source, space).unwrap();
462 assert_eq!(
463 projection(&before),
464 projection(&after),
465 "an unchanged model publishes nothing"
466 );
467 return;
468 }
469 current::current(written);
470 let stored = model(written, space).expect("the written page reads back");
471 let (stored, expected) = (projection(&stored), projection(&after));
472 if stored != expected {
473 let differences: Vec<String> = stored
474 .lines()
475 .zip(expected.lines())
476 .filter(|(a, b)| a != b)
477 .map(|(a, b)| format!("stored: {a}\nexpected: {b}"))
478 .collect();
479 panic!(
480 "model round trip ({} vs {} lines):\n{}",
481 stored.lines().count(),
482 expected.lines().count(),
483 differences.join("\n")
484 );
485 }
486 let raw_before = index.resolve_active(space).unwrap();
487 let store = Store::parse(written).unwrap();
488 let index = RevisionIndex::parse(&store).unwrap();
489 let raw_after = index.resolve_active(space).unwrap();
490 let mut modeled = BTreeSet::from([page_object]);
491 for object in &after.objects {
492 modeled.insert(object.id());
493 let outlines: Vec<&Outline> = match object {
494 PageObject::Outline(outline) => vec![outline],
495 PageObject::Title(title) => title.outlines.iter().collect(),
496 _ => Vec::new(),
497 };
498 for outline in outlines {
499 modeled.insert(outline.id);
500 for paragraph in &outline.paragraphs {
501 modeled.insert(paragraph.id);
502 if let Some(text) = paragraph.text() {
503 modeled.insert(text.id);
504 }
505 }
506 }
507 }
508 // Revision roots carry derived metadata such as the automatic navigation title, and
509 // outline groups (0x60019) are containers the model flattens but the writers normalize.
510 let roots: BTreeSet<ExGuid> = raw_before.roots.values().copied().collect();
511 for (id, object) in &raw_before.objects {
512 if modeled.contains(id)
513 || roots.contains(id)
514 || matches!(object.jcid, 0x12004d | 0x120001 | 0x60019)
515 {
516 continue;
517 }
518 let Some(after) = raw_after.objects.get(id) else {
519 panic!("{id} disappeared from the active revision");
520 };
521 assert_eq!(
522 (object.jcid, object.data),
523 (after.jcid, after.data),
524 "{id} changed outside the model"
525 );
526 }
527}
fuzz/Cargo.toml+7
......@@ -136,3 +136,10 @@ path = "fuzz_targets/canvas_editor.rs"
136136test = false
137137doc = false
138138bench = false
139
140[[bin]]
141name = "page_model"
142path = "fuzz_targets/page_model.rs"
143test = false
144doc = false
145bench = false
fuzz/fuzz_targets/page_model.rs created+7
......@@ -0,0 +1,7 @@
1#![no_main]
2use libfuzzer_sys::fuzz_target;
3
4#[path = "../../crates/onestore/tests/support/page_edits.rs"]
5mod page_edits;
6
7fuzz_target!(|input: &[u8]| page_edits::run(input));