From de01c779539ae34b40906112aa468ef3a52979fa Mon Sep 17 00:00:00 2001 From: clover caruso Date: Wed, 9 Sep 2026 01:08:47 -0700 Subject: [PATCH] feat: delete pages permanently Add PreparedEdit::delete_pages_permanently: remove explicitly selected page spaces and their section references in one transaction. Removed pages keep their declarations with an empty manifest and the deleted-content flag; the first survivor is promoted to level one, other levels and series identities are retained, and no recycle-bin copy is written. Page placement and removal share one writer; the removal-reference corpus seeds the page fuzz target. Validation: 298 workspace tests including six deletion oracles against the native removal references and twelve-client removal schedules; Clippy and fuzz targets build. Native editing after a Rust removal is not yet verified; offline replay of removal is deliberately not added pending the page-model redesign in evidence/ROADMAP.md. Assisted-by: claude-fable-5.1 --- crates/onestore/README.md | 8 + crates/onestore/src/commit.rs | 15 +- crates/onestore/src/page.rs | 482 ++++++++++-------- crates/onestore/tests/page_deletion.rs | 371 ++++++++++++++ .../onestore/tests/support/page_schedule.rs | 275 ++++++++++ fuzz/fuzz_targets/page.rs | 238 +-------- readme.md | 5 + 7 files changed, 942 insertions(+), 452 deletions(-) create mode 100644 crates/onestore/tests/page_deletion.rs create mode 100644 crates/onestore/tests/support/page_schedule.rs diff --git a/crates/onestore/README.md b/crates/onestore/README.md index a1924eb03c1771ad3255274bec07b9989e386f71..0b014e81d2af402f96bf9b02bb1adac08b9a07c9 100644 --- a/crates/onestore/README.md +++ b/crates/onestore/README.md @@ -47,6 +47,7 @@ harness also accepts `--client-profile release`. | `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode | | `PageCreation`, `PreparedEdit::create_page` | Add an empty top-level page and its section entry atomically, retaining page identities across retries | | `PageEdit`, `PreparedEdit::pages` | Publish explicitly selected page moves and indentation changes together, preserving page content and historical revisions | +| `PreparedEdit::delete_pages_permanently` | Remove explicit pages and their section references atomically while retaining stored revisions | | `create_table_of_contents` | Create ordered section entries from filenames and file identities | | `replace_property_bytes` | Append one scalar-property revision; preserve prior revisions and unrelated property values and references | | `replace_text`, `commit_text`, `commit_file_text` | Replace a UTF-16 range across ordinary text runs; publish text, run boundaries and modification time together | @@ -92,6 +93,13 @@ pages in order. Retain the intents across retries so newly formed series keep their identities; `reposition(PagePosition, level)` revises their placement while preserving those identities. [Native page-edit fixtures](../../corpus/page-lifecycle/page-edits/README.md) cover individual tabs, selected and collapsed groups, nesting and promotion. +`PreparedEdit::delete_pages_permanently` removes exactly the supplied page spaces, +including subpages only when selected explicitly. The first remaining page becomes +top-level; other page levels and surviving content are retained. The operation +creates no recycle-bin copies and preserves prior revisions, so it is not secure +erasure. Duplicate, missing or non-page identities reject the entire batch; +an empty selection leaves the file unchanged. Commit uses the exact source +snapshot and rejects a stale prepared removal before writing. Insertions update child references, reference counts, modification times and automatic titles atomically. Paragraphs can be nested or inserted into table cells; outline coordinates use points. `Insertion::with_formatting` includes nonoverlapping UTF-16 diff --git a/crates/onestore/src/commit.rs b/crates/onestore/src/commit.rs index ddf2a0e427c2bd20f5103a98993f0d5ca7e7141e..c5f41bec49f49f0557d494e02073b84e0c80c92f 100644 --- a/crates/onestore/src/commit.rs +++ b/crates/onestore/src/commit.rs @@ -188,7 +188,20 @@ impl<'a> PreparedEdit<'a> { pub fn pages(source: &'a [u8], edits: &[crate::PageEdit]) -> Result { Ok(Self { source, - written: crate::PageEdit::apply(source, edits)?, + written: crate::page::edit_pages(source, edits, &[])?, + }) + } + + /// Permanently removes explicitly selected pages from the section in one transaction. + /// Subpages must be selected explicitly; a surviving first subpage becomes top-level. + /// Creates no recycle-bin copies. Prior revisions remain stored; this is not secure erasure. + pub fn delete_pages_permanently( + source: &'a [u8], + pages: &[crate::ExGuid], + ) -> Result { + Ok(Self { + source, + written: crate::page::edit_pages(source, &[], pages)?, }) } diff --git a/crates/onestore/src/page.rs b/crates/onestore/src/page.rs index ea63632a72a220c741a1db8b36bcb921982f5a0e..0337065751d4c363d2af759084a129be8322aec2 100644 --- a/crates/onestore/src/page.rs +++ b/crates/onestore/src/page.rs @@ -127,234 +127,279 @@ impl PageEdit { } Ok(()) } +} - pub(crate) fn apply(source: &[u8], edits: &[Self]) -> Result, Error> { - let mut selected = BTreeMap::new(); - let mut guids = BTreeSet::new(); +pub(crate) fn edit_pages( + source: &[u8], + edits: &[PageEdit], + removals: &[ExGuid], +) -> Result, Error> { + if !edits.is_empty() && !removals.is_empty() { + return Err(invalid( + "Choose page placement or page removal for one operation", + )); + } + let removed: BTreeSet<_> = removals.iter().copied().collect(); + if removed.len() != removals.len() || removed.iter().any(|sid| sid.guid == [0; 16]) { + return Err(invalid("Choose distinct existing page spaces for removal")); + } + let mut selected = BTreeMap::new(); + let mut guids = BTreeSet::new(); + for edit in edits { + edit.validate()?; + if selected.insert(edit.space, edit).is_some() || !guids.insert(edit.guid) { + return Err(invalid( + "Use distinct page and edit identities within a batch", + )); + } + } + write_revisions(source, |index| { + let document = Document::parse(index)?; + let pages = document.pages()?; + let section = &document.spaces[&document.root]; + let view = §ion.revisions[§ion.contexts[&ExGuid::default()]]; + let section_id = view.roots[&1]; + let section_node = &view.nodes[§ion_id]; + if !matches!(section_node.kind, Kind::Section { .. }) + || section_node.extra[0] + .iter() + .any(|field| field.id == 0x88001cde) + { + return Err(invalid("Choose an editable section for page editing")); + } + let mut order = Vec::new(); + let mut levels = BTreeMap::new(); + let mut page_guids = BTreeMap::new(); + for (sid, _) in &pages { + let page = &document.spaces[sid]; + let page = &page.revisions[&page.contexts[&ExGuid::default()]]; + let Some(metadata) = page.roots.get(&2).and_then(|id| page.nodes.get(id)) else { + return Err(invalid("Page metadata is unavailable")); + }; + let Kind::Metadata { level, .. } = metadata.kind else { + return Err(invalid("Choose ordinary pages with page metadata")); + }; + let level = level.unwrap_or(1); + if !(1..=3).contains(&level) || levels.insert(*sid, level).is_some() { + return Err(invalid( + "Each page space must contain one page with a valid level", + )); + } + if page_guids.insert(metadata_guid(metadata)?, *sid).is_some() { + return Err(invalid("Pages must have distinct metadata identifiers")); + } + order.push(*sid); + } + let original_order = order.clone(); + let original_levels = levels.clone(); + let mut series_by_head = BTreeMap::new(); + let mut copies = BTreeMap::new(); + let mut copy_ids = BTreeSet::new(); + for oid in §ion_node.children { + let series = &view.nodes[oid]; + let Some(head) = series.spaces.first() else { + return Err(invalid("A page series must contain at least one page")); + }; + if levels.get(head) != Some(&1) + || series.spaces[1..] + .iter() + .any(|sid| levels.get(sid).is_none_or(|level| *level == 1)) + { + return Err(invalid( + "A page series must start with its only top-level page", + )); + } + if let Some(retained) = series.spaces.iter().find(|sid| !removed.contains(sid)) { + series_by_head.insert(*retained, *oid); + } + if let Some(field) = series.extra[0].iter().find(|field| field.id == 0x24003442) { + let FieldValue::Objects(ids) = &field.value else { + return Err(invalid("Page metadata copies must be object references")); + }; + if ids.len() != series.spaces.len() { + return Err(invalid("Page metadata copies must match their series")); + } + for id in ids { + if !copy_ids.insert(*id) + || !matches!( + view.nodes.get(id).map(|n| &n.kind), + Some(Kind::Metadata { .. }) + ) + { + return Err(invalid("Each page needs its own ordinary metadata copy")); + } + let sid = page_guids + .get(&metadata_guid(&view.nodes[id])?) + .filter(|sid| series.spaces.contains(sid)) + .ok_or_else(|| { + invalid("Metadata copy must identify a page in its series") + })?; + if copies.insert(*sid, *id).is_some() { + return Err(invalid("Each page needs its own ordinary metadata copy")); + } + } + } + } + for sid in &removed { + if !levels.contains_key(sid) { + return Err(invalid("The selected page is no longer in the section")); + } + } + order.retain(|sid| !removed.contains(sid)); + if !removed.is_empty() + && let Some(first) = order.first() + { + levels.insert(*first, 1); + } for edit in edits { - edit.validate()?; - if selected.insert(edit.space, edit).is_some() || !guids.insert(edit.guid) { - return Err(invalid( - "Use distinct page and edit identities within a batch", - )); - } - } - write_revisions(source, |index| { - let document = Document::parse(index)?; - let pages = document.pages()?; - let section = &document.spaces[&document.root]; - let view = §ion.revisions[§ion.contexts[&ExGuid::default()]]; - let section_id = view.roots[&1]; - let section_node = &view.nodes[§ion_id]; - if !matches!(section_node.kind, Kind::Section { .. }) - || section_node.extra[0] - .iter() - .any(|field| field.id == 0x88001cde) - { - return Err(invalid("Choose an editable section for page movement")); - } - let mut order = Vec::new(); - let mut levels = BTreeMap::new(); - let mut page_guids = BTreeMap::new(); - for (sid, _) in &pages { - let page = &document.spaces[sid]; - let page = &page.revisions[&page.contexts[&ExGuid::default()]]; - let Some(metadata) = page.roots.get(&2).and_then(|id| page.nodes.get(id)) else { - return Err(invalid("Page metadata is unavailable")); - }; - let Kind::Metadata { level, .. } = metadata.kind else { - return Err(invalid("Choose ordinary pages with page metadata")); - }; - let level = level.unwrap_or(1); - if !(1..=3).contains(&level) || levels.insert(*sid, level).is_some() { - return Err(invalid( - "Each page space must contain one page with a valid level", - )); - } - if page_guids.insert(metadata_guid(metadata)?, *sid).is_some() { - return Err(invalid("Pages must have distinct metadata identifiers")); - } - order.push(*sid); - } - let original_order = order.clone(); - let original_levels = levels.clone(); - let mut series_by_head = BTreeMap::new(); - let mut copies = BTreeMap::new(); - let mut copy_ids = BTreeSet::new(); - for oid in §ion_node.children { - let series = &view.nodes[oid]; - let Some(head) = series.spaces.first() else { - return Err(invalid("A page series must contain at least one page")); - }; - if levels.get(head) != Some(&1) - || series.spaces[1..] - .iter() - .any(|sid| levels.get(sid).is_none_or(|level| *level == 1)) - { - return Err(invalid( - "A page series must start with its only top-level page", - )); - } - series_by_head.insert(*head, *oid); - if let Some(field) = series.extra[0].iter().find(|field| field.id == 0x24003442) { - let FieldValue::Objects(ids) = &field.value else { - return Err(invalid("Page metadata copies must be object references")); - }; - if ids.len() != series.spaces.len() { - return Err(invalid("Page metadata copies must match their series")); - } - for id in ids { - if !copy_ids.insert(*id) - || !matches!( - view.nodes.get(id).map(|n| &n.kind), - Some(Kind::Metadata { .. }) - ) - { - return Err(invalid("Each page needs its own ordinary metadata copy")); - } - let sid = page_guids - .get(&metadata_guid(&view.nodes[id])?) - .filter(|sid| series.spaces.contains(sid)) - .ok_or_else(|| { - invalid("Metadata copy must identify a page in its series") - })?; - if copies.insert(*sid, *id).is_some() { - return Err(invalid("Each page needs its own ordinary metadata copy")); - } + let Some(at) = order.iter().position(|sid| *sid == edit.space) else { + return Err(invalid("The selected page is no longer in the section")); + }; + levels.insert(edit.space, edit.level); + if let PagePosition::Before(before) = edit.position { + order.remove(at); + let position = match before { + None => order.len(), + Some(before) => { + order.iter().position(|sid| *sid == before).ok_or_else(|| { + invalid("The movement anchor is no longer in the section") + })? } - } - } - for edit in edits { - let Some(at) = order.iter().position(|sid| *sid == edit.space) else { - return Err(invalid("The selected page is no longer in the section")); }; - levels.insert(edit.space, edit.level); - if let PagePosition::Before(before) = edit.position { - order.remove(at); - let position = match before { - None => order.len(), - Some(before) => { - order.iter().position(|sid| *sid == before).ok_or_else(|| { - invalid("The movement anchor is no longer in the section") - })? - } - }; - order.insert(position, edit.space); - } - } - if order.first().is_some_and(|sid| levels[sid] != 1) { - return Err(invalid( - "The section's first page must have indentation level 1", - )); + order.insert(position, edit.space); } - if order == original_order && levels == original_levels { - return Ok(BTreeMap::new()); + } + if order.first().is_some_and(|sid| levels[sid] != 1) { + return Err(invalid( + "The section's first page must have indentation level 1", + )); + } + if order == original_order && levels == original_levels { + return Ok(BTreeMap::new()); + } + let raw = index.resolve(document.root, section.contexts[&ExGuid::default()])?; + let mut groups: Vec> = Vec::new(); + for sid in order { + if levels[&sid] == 1 { + groups.push(Vec::new()); } - let raw = index.resolve(document.root, section.contexts[&ExGuid::default()])?; - let mut groups: Vec> = Vec::new(); - for sid in order { - if levels[&sid] == 1 { - groups.push(Vec::new()); - } - groups.last_mut().unwrap().push(sid); + groups.last_mut().unwrap().push(sid); + } + let mut changes = BTreeMap::new(); + for sid in &removed { + let page = index.resolve(*sid, index.spaces[sid].labels[&(ExGuid::default(), 1)])?; + let manifest_id = page.roots[&1]; + let mut manifest = PropertyObject::from_object(&page.objects[&manifest_id])?; + if manifest.jcid != 0x60037 { + return Err(invalid("Choose ordinary pages for removal")); } - let mut changes = BTreeMap::new(); - let mut replacements = BTreeMap::new(); - let mut children = Vec::new(); - for spaces in groups { - let head = spaces[0]; - let old_id = series_by_head.get(&head).copied(); - let (id, mut series) = if let Some(id) = old_id { - (id, PropertyObject::from_object(&raw.objects[&id])?) - } else { - let edit = selected - .get(&head) - .ok_or_else(|| invalid("A new series must start at an edited page"))?; - let id = ExGuid { - guid: edit.guid, - n: 1, - }; - if raw.objects.contains_key(&id) { - return Err(invalid("The new page-series identity already exists")); - } - ( - id, - PropertyObject { - jcid: 0x60008, - bytes: properties(&[(0x1c001c30, edit.guid.to_vec())])?, - global_ids: Arc::new(BTreeMap::from([(0, edit.guid)])), - }, - ) + manifest.bytes = properties(&[])?; + let metadata_id = page.roots[&2]; + let mut metadata = PropertyObject::from_object(&page.objects[&metadata_id])?; + metadata.set(&[(0x88001de9, &[])])?; + changes.insert( + *sid, + RevisionEdit::Update(BTreeMap::from([ + (manifest_id, manifest), + (metadata_id, metadata), + ])), + ); + } + let mut replacements = BTreeMap::new(); + let mut children = Vec::new(); + for spaces in groups { + let head = spaces[0]; + let old_id = series_by_head.get(&head).copied(); + let (id, mut series) = if let Some(id) = old_id { + (id, PropertyObject::from_object(&raw.objects[&id])?) + } else { + let edit = selected + .get(&head) + .ok_or_else(|| invalid("A new series must start at an edited page"))?; + let id = ExGuid { + guid: edit.guid, + n: 1, }; - children.push(id); - let membership_changed = old_id.is_none_or(|old| view.nodes[&old].spaces != spaces); - if !membership_changed - && !spaces.iter().any(|sid| { - levels[sid] != original_levels[sid] - || copies.get(sid).is_some_and(|id| { - matches!(view.nodes[id].kind, Kind::Metadata { level, .. } + if raw.objects.contains_key(&id) { + return Err(invalid("The new page-series identity already exists")); + } + ( + id, + PropertyObject { + jcid: 0x60008, + bytes: properties(&[(0x1c001c30, edit.guid.to_vec())])?, + global_ids: Arc::new(BTreeMap::from([(0, edit.guid)])), + }, + ) + }; + children.push(id); + let membership_changed = old_id.is_none_or(|old| view.nodes[&old].spaces != spaces); + if !membership_changed + && !spaces.iter().any(|sid| { + levels[sid] != original_levels[sid] + || copies.get(sid).is_some_and(|id| { + matches!(view.nodes[id].kind, Kind::Metadata { level, .. } if level.unwrap_or(1) != levels[sid]) - }) - }) - { - continue; + }) + }) + { + continue; + } + let mut metadata_ids = Vec::new(); + for sid in &spaces { + let page = + index.resolve(*sid, index.spaces[sid].labels[&(ExGuid::default(), 1)])?; + let page_id = page.roots[&2]; + let original = PropertyObject::from_object(&page.objects[&page_id])?; + if old_id.is_none() && *sid == head { + series.copy_property(&original, 0x18001c65)?; } - let mut metadata_ids = Vec::new(); - for sid in &spaces { - let page = - index.resolve(*sid, index.spaces[sid].labels[&(ExGuid::default(), 1)])?; - let page_id = page.roots[&2]; - let original = PropertyObject::from_object(&page.objects[&page_id])?; - if old_id.is_none() && *sid == head { - series.copy_property(&original, 0x18001c65)?; - } - if levels[sid] != original_levels[sid] { - let mut metadata = PropertyObject::from_object(&page.objects[&page_id])?; - metadata.set(&[(0x14001dff, &levels[sid].to_le_bytes())])?; - changes.insert( - *sid, - RevisionEdit::Update(BTreeMap::from([(page_id, metadata)])), - ); - } - let copy_id = copies - .get(sid) - .copied() - .unwrap_or_else(|| metadata_id(*sid)); - let mut copy = match raw.objects.get(©_id) { - Some(object) => { - if object.jcid != 0x20030 - || (!copies.contains_key(sid) && copy_ids.contains(©_id)) - { - return Err(invalid("Page metadata identities overlap")); - } - PropertyObject::from_object(object)? + if levels[sid] != original_levels[sid] { + let mut metadata = PropertyObject::from_object(&page.objects[&page_id])?; + metadata.set(&[(0x14001dff, &levels[sid].to_le_bytes())])?; + changes.insert( + *sid, + RevisionEdit::Update(BTreeMap::from([(page_id, metadata)])), + ); + } + let copy_id = copies + .get(sid) + .copied() + .unwrap_or_else(|| metadata_id(*sid)); + let mut copy = match raw.objects.get(©_id) { + Some(object) => { + if object.jcid != 0x20030 + || (!copies.contains_key(sid) && copy_ids.contains(©_id)) + { + return Err(invalid("Page metadata identities overlap")); } - None => original, - }; - copy.set(&[(0x14001dff, &levels[sid].to_le_bytes())])?; - copy.reference(copy_id)?; - if replacements.insert(copy_id, copy).is_some() { - return Err(invalid("Page metadata identities overlap")); - } - metadata_ids.push(copy_id); - } - if membership_changed || spaces.iter().any(|sid| !copies.contains_key(sid)) { - set_references(&mut series, 0x2c001d63, &spaces)?; - set_references(&mut series, 0x24003442, &metadata_ids)?; - if replacements.insert(id, series).is_some() { - return Err(invalid("Page-series and metadata identities overlap")); + PropertyObject::from_object(object)? } + None => original, + }; + copy.set(&[(0x14001dff, &levels[sid].to_le_bytes())])?; + copy.reference(copy_id)?; + if replacements.insert(copy_id, copy).is_some() { + return Err(invalid("Page metadata identities overlap")); } + metadata_ids.push(copy_id); } - if children != section_node.children { - let mut parent = PropertyObject::from_object(&raw.objects[§ion_id])?; - set_references(&mut parent, 0x24001c20, &children)?; - replacements.insert(section_id, parent); + if membership_changed || spaces.iter().any(|sid| !copies.contains_key(sid)) { + set_references(&mut series, 0x2c001d63, &spaces)?; + set_references(&mut series, 0x24003442, &metadata_ids)?; + if replacements.insert(id, series).is_some() { + return Err(invalid("Page-series and metadata identities overlap")); + } } - changes.insert(document.root, RevisionEdit::Update(replacements)); - Ok(changes) - }) - } + } + if children != section_node.children { + let mut parent = PropertyObject::from_object(&raw.objects[§ion_id])?; + set_references(&mut parent, 0x24001c20, &children)?; + replacements.insert(section_id, parent); + } + changes.insert(document.root, RevisionEdit::Update(replacements)); + Ok(changes) + }) } /// An empty top-level page with stable identities and creation time. @@ -685,7 +730,7 @@ mod tests { } } let written = - PageEdit::apply(&source, &[PageEdit::set_level(sid, 2).unwrap()]).unwrap(); + edit_pages(&source, &[PageEdit::set_level(sid, 2).unwrap()], &[]).unwrap(); let store = Store::parse(&written).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); index.validate_current().unwrap(); @@ -761,7 +806,12 @@ mod tests { }) .unwrap(); assert!( - PageEdit::apply(&invalid_source, &[PageEdit::set_level(sid, 2).unwrap()]).is_err() + edit_pages( + &invalid_source, + &[PageEdit::set_level(sid, 2).unwrap()], + &[] + ) + .is_err() ); } } @@ -810,7 +860,7 @@ mod tests { .map(|(i, (sid, _))| PageEdit::move_to(*sid, None, i as u32 + 1).unwrap()) .collect(), ] { - let written = PageEdit::apply(&source, &edits).unwrap(); + let written = edit_pages(&source, &edits, &[]).unwrap(); let old_store = Store::parse(&source).unwrap(); let new_store = Store::parse(&written).unwrap(); let old = RevisionIndex::parse(&old_store).unwrap(); diff --git a/crates/onestore/tests/page_deletion.rs b/crates/onestore/tests/page_deletion.rs new file mode 100644 index 0000000000000000000000000000000000000000..1dd8deab5e63a8f5396de11c9d8e79ffc432b44c --- /dev/null +++ b/crates/onestore/tests/page_deletion.rs @@ -0,0 +1,371 @@ +use onestore::{ + ExGuid, FileDataReference, ObjectData, PreparedEdit, PropertySets, RevisionIndex, Store, + document::Document, +}; +use std::collections::BTreeSet; + +const SOURCE: &[u8] = + include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); + +fn verify(original: &[u8], written: &[u8], native: &[u8], removed: &[ExGuid]) { + let stores = [original, written, native].map(|bytes| Store::parse(bytes).unwrap()); + let indexes = stores.each_ref().map(|store| { + assert!(store.checksum_mismatches.is_empty()); + let index = RevisionIndex::parse(store).unwrap(); + index.validate_current().unwrap(); + index + }); + let documents = indexes + .each_ref() + .map(|index| Document::parse(index).unwrap()); + assert_eq!(stores[0].header.file_id, stores[1].header.file_id); + assert_eq!( + stores[1].header.transaction_count, + stores[0].header.transaction_count + 1 + ); + assert_eq!( + indexes[0].spaces.keys().collect::>(), + indexes[1].spaces.keys().collect::>() + ); + let expected: Vec<_> = documents[0] + .pages() + .unwrap() + .into_iter() + .filter(|(sid, _)| !removed.contains(sid)) + .collect(); + assert_eq!(documents[1].pages().unwrap(), expected); + assert_eq!(documents[2].pages().unwrap(), expected); + assert_eq!( + documents[1].spaces.keys().collect::>(), + documents[2].spaces.keys().collect::>() + ); + for (sid, space) in &documents[1].spaces { + let expected = &documents[2].spaces[sid]; + assert_eq!( + space.contexts.keys().collect::>(), + expected.contexts.keys().collect::>() + ); + for (context, rid) in &space.contexts { + let actual = &space.revisions[rid]; + let expected = &expected.revisions[&expected.contexts[context]]; + assert_eq!(actual.roots, expected.roots); + assert!( + serde_json::to_value(&actual.nodes).unwrap() + == serde_json::to_value(&expected.nodes).unwrap(), + "native page graph {sid}/{context}" + ); + } + } + let mut payloads = BTreeSet::new(); + for (sid, space) in &indexes[0].spaces { + for rid in space.revisions.keys() { + let before = indexes[0].resolve(*sid, *rid).unwrap(); + let after = indexes[1].resolve(*sid, *rid).unwrap(); + assert_eq!( + format!("{before:?}"), + format!("{after:?}"), + "prior revision {sid}/{rid}" + ); + for object in before.objects.values() { + if let Some(FileDataReference::Internal(guid)) = object.file_reference().unwrap() { + payloads.insert(guid); + } + } + } + if removed.contains(sid) { + assert!(!documents[1].spaces.contains_key(sid)); + let before = indexes[0] + .resolve(*sid, space.labels[&(ExGuid::default(), 1)]) + .unwrap(); + let after = indexes[1] + .resolve(*sid, indexes[1].spaces[sid].labels[&(ExGuid::default(), 1)]) + .unwrap(); + assert_eq!(before.roots, after.roots); + let ObjectData::Properties(bytes) = after.objects[&after.roots[&1]].data else { + panic!() + }; + assert!(PropertySets::parse(bytes).unwrap().sets[0].is_empty()); + let ObjectData::Properties(bytes) = after.objects[&after.roots[&2]].data else { + panic!() + }; + let metadata = PropertySets::parse(bytes).unwrap(); + assert!(metadata.sets[0].iter().any(|p| p.id == 0x88001de9)); + let native = indexes[2] + .resolve(*sid, indexes[2].spaces[sid].labels[&(ExGuid::default(), 1)]) + .unwrap(); + assert_eq!(after.reachable().unwrap(), native.reachable().unwrap()); + for (label, rid) in &space.labels { + if *label != (ExGuid::default(), 1) { + assert_eq!(indexes[1].spaces[sid].labels[label], *rid); + } + } + } + } + for guid in payloads { + assert_eq!( + stores[0].file_data(guid).unwrap(), + stores[1].file_data(guid).unwrap() + ); + } +} + +#[test] +fn removal_matches_all_native_cases_and_retains_every_old_revision() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../corpus/page-lifecycle/removal"); + let cases: serde_json::Value = + serde_json::from_slice(&std::fs::read(root.join("provenance.json")).unwrap()).unwrap(); + for (name, case) in cases.as_object().unwrap() { + let original = + std::fs::read(root.join(name).join("native/before/notebook/Lifecycle.one")).unwrap(); + let native = + std::fs::read(root.join(name).join("native/after/notebook/Lifecycle.one")).unwrap(); + let store = Store::parse(&original).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let pages = document.pages().unwrap(); + let selected: Vec = + serde_json::from_value(case["request"]["selected"].clone()).unwrap(); + let removed: Vec<_> = selected + .into_iter() + .map(|ordinal| pages[ordinal].0) + .collect(); + let prepared = PreparedEdit::delete_pages_permanently(&original, &removed).unwrap(); + if let Some(output) = std::env::var_os("ONESTORE_PAGE_REMOVAL_OUTPUT") { + let output = std::path::Path::new(&output); + assert!(output.is_absolute()); + std::fs::create_dir_all(output).unwrap(); + let case = output.join(name); + std::fs::create_dir(&case).unwrap(); + std::fs::write(case.join("Lifecycle.one"), prepared.as_bytes()).unwrap(); + } + verify(&original, prepared.as_bytes(), &native, &removed); + assert!(PreparedEdit::delete_pages_permanently(prepared.as_bytes(), &removed).is_err()); + } +} + +#[test] +fn removal_rejects_duplicate_missing_and_non_page_spaces_without_publication() { + let store = Store::parse(SOURCE).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let pages = document.pages().unwrap(); + for selected in [ + vec![pages[0].0, pages[0].0], + vec![index.root], + vec![ExGuid::default()], + vec![ExGuid { + guid: [97; 16], + n: 1, + }], + vec![pages[0].0, index.root], + ] { + assert!(PreparedEdit::delete_pages_permanently(SOURCE, &selected).is_err()); + } + assert_eq!( + PreparedEdit::delete_pages_permanently(SOURCE, &[]) + .unwrap() + .as_bytes(), + SOURCE + ); + let written = PreparedEdit::delete_pages_permanently(SOURCE, &[pages[0].0]).unwrap(); + assert!( + PreparedEdit::delete_pages_permanently(written.as_bytes(), &[pages[1].0, pages[0].0]) + .is_err() + ); +} + +use page_schedule::{current, disk}; + +#[test] +fn tombstones_and_first_page_promotion_survive_each_storage_interruption() { + let leading = include_bytes!( + "../../../corpus/page-lifecycle/removal/leading-parent/native/before/notebook/Lifecycle.one" + ); + for (source, all) in [(leading.as_slice(), false), (SOURCE, true)] { + let store = Store::parse(source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let pages = Document::parse(&index).unwrap().pages().unwrap(); + let removed: Vec<_> = pages + .iter() + .take(if all { pages.len() } else { 1 }) + .map(|page| page.0) + .collect(); + let prepared = PreparedEdit::delete_pages_permanently(source, &removed).unwrap(); + let old = current::current(source); + let new = current::current(prepared.as_bytes()); + assert_ne!(old, new); + assert_eq!( + old.keys().collect::>(), + new.keys().collect::>() + ); + for write_limit in [1, 17, 4096] { + let mut complete = disk::Disk { + visible: source.to_vec(), + durable: source.to_vec(), + operation: 0, + fail_at: None, + write_limit, + random: 1997, + }; + prepared.commit(&mut complete).unwrap(); + assert_eq!(complete.durable, prepared.as_bytes()); + for fail_at in 1..=complete.operation { + let mut interrupted = disk::Disk { + visible: source.to_vec(), + durable: source.to_vec(), + operation: 0, + fail_at: Some(fail_at), + write_limit, + random: 1997 + u64::try_from(fail_at).unwrap(), + }; + let error = prepared.commit(&mut interrupted).unwrap_err(); + let observed = current::current(&interrupted.durable); + assert!( + observed == old || observed == new, + "{all}:{write_limit}:{fail_at}" + ); + match error.state { + onestore::CommitState::NotCommitted => assert_eq!(observed, old), + onestore::CommitState::Committed => assert_eq!(observed, new), + onestore::CommitState::Unknown => {} + } + } + } + } +} + +#[test] +fn stale_removal_cannot_delete_a_newer_page_edit() { + let store = Store::parse(SOURCE).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let sid = document.pages().unwrap()[3].0; + let space = &document.spaces[&sid]; + let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; + let text = revision + .nodes + .iter() + .find_map(|(id, node)| match &node.kind { + onestore::document::Kind::RichText { + text, + boilerplate: false, + .. + } if !text.is_empty() => Some(*id), + _ => None, + }) + .unwrap(); + let removal = PreparedEdit::delete_pages_permanently(SOURCE, &[sid]).unwrap(); + let remote = onestore::replace_text(SOURCE, sid, text, 0..0, "New remote content ").unwrap(); + let mut disk = disk::Disk { + visible: remote.clone(), + durable: remote.clone(), + operation: 0, + fail_at: None, + write_limit: 17, + random: 1999, + }; + let error = removal.commit(&mut disk).unwrap_err(); + assert_eq!(error.state, onestore::CommitState::NotCommitted); + assert_eq!(error.error.kind(), std::io::ErrorKind::ResourceBusy); + assert_eq!(disk.visible, remote); + assert_eq!(disk.durable, remote); +} + +#[path = "support/page_schedule.rs"] +mod page_schedule; + +#[test] +fn twelve_client_page_schedules_include_removal_empty_sections_and_stale_edits() { + page_schedule::run(&[ + 0, 192, 0, 128, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 2, 18, 0, 0, 0, 0, 0, 0, 3, 192, 0, + 128, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, + ]); + for seed in 1_u64..=32 { + let mut random = seed * 2003; + let mut input = [0; 192]; + for byte in &mut input { + random ^= random << 13; + random ^= random >> 7; + random ^= random << 17; + *byte = random.to_le_bytes()[0]; + } + page_schedule::run(&input); + } +} + +#[test] +fn external_payloads_and_their_historical_references_survive_file_removal() { + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../corpus/native-external-assets/notebook"); + let original = std::fs::read(fixture.join("synthetic.one")).unwrap(); + let store = Store::parse(&original).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let pages = Document::parse(&index).unwrap().pages().unwrap(); + assert_eq!(pages.len(), 3); + let selected: Vec<_> = pages.iter().map(|page| page.0).collect(); + let prepared = PreparedEdit::delete_pages_permanently(&original, &selected).unwrap(); + let output = std::env::var_os("ONESTORE_PAGE_REMOVAL_EXTERNAL_OUTPUT"); + let root = output + .as_ref() + .map(std::path::PathBuf::from) + .unwrap_or_else(|| { + std::env::temp_dir().join(format!( + "onestore-page-removal-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )) + }); + assert!(root.is_absolute()); + std::fs::create_dir(&root).unwrap(); + let files = root.join("synthetic_onefiles"); + std::fs::create_dir(&files).unwrap(); + let mut payloads = std::collections::BTreeMap::new(); + for entry in std::fs::read_dir(fixture.join("synthetic_onefiles")).unwrap() { + let entry = entry.unwrap(); + let bytes = std::fs::read(entry.path()).unwrap(); + std::fs::write(files.join(entry.file_name()), &bytes).unwrap(); + payloads.insert(entry.file_name(), bytes); + } + assert_eq!(payloads.len(), 3); + let path = root.join("synthetic.one"); + std::fs::write(&path, &original).unwrap(); + prepared.commit_file(&path).unwrap(); + let written = onestore::read_file(&path).unwrap(); + assert_eq!(written, prepared.as_bytes()); + let current_store = Store::parse(&written).unwrap(); + let current_index = RevisionIndex::parse(¤t_store).unwrap(); + current_index.validate_current().unwrap(); + assert!( + Document::parse(¤t_index) + .unwrap() + .pages() + .unwrap() + .is_empty() + ); + let mut external = BTreeSet::new(); + for (sid, space) in &index.spaces { + for rid in space.revisions.keys() { + let before = index.resolve(*sid, *rid).unwrap(); + let after = current_index.resolve(*sid, *rid).unwrap(); + assert_eq!(format!("{before:?}"), format!("{after:?}")); + for object in before.objects.values() { + if let Some(FileDataReference::External(reference)) = + object.file_reference().unwrap() + { + external.insert(reference); + } + } + } + } + assert_eq!(external.len(), 3); + for (name, bytes) in payloads { + assert_eq!(std::fs::read(files.join(name)).unwrap(), bytes); + } + if output.is_none() { + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/onestore/tests/support/page_schedule.rs b/crates/onestore/tests/support/page_schedule.rs new file mode 100644 index 0000000000000000000000000000000000000000..d25a2fa72ac9d86fd4d661d66dcbc159e9ca5d75 --- /dev/null +++ b/crates/onestore/tests/support/page_schedule.rs @@ -0,0 +1,275 @@ +use onestore::{ + CommitState, ExGuid, Insertion, PageCreation, PageEdit, PreparedEdit, RevisionIndex, Store, + document::{Document, FieldValue, Kind}, +}; +use std::{collections::BTreeMap, sync::LazyLock}; + +#[path = "current.rs"] +pub(crate) mod current; +#[path = "disk.rs"] +pub(crate) mod disk; + +static SOURCE: LazyLock> = LazyLock::new(|| { + onestore::create_section("pages.one", "Original 🦀 é 東京", "Author").unwrap() +}); + +pub fn run(input: &[u8]) { + let source = match input.first().copied().unwrap_or(0) % 5 { + 1 => include_bytes!("../../../../corpus/page-lifecycle/page-edits/optional-cache/source/Lifecycle.one") + .as_slice(), + 2 => include_bytes!("../../../../corpus/page-lifecycle/page-edits/optional-cache/source-cold/notebook/Lifecycle.one") + .as_slice(), + 3 => include_bytes!("../../../../corpus/page-lifecycle/removal/features/native/before/notebook/Lifecycle.one").as_slice(), + 4 => include_bytes!("../../../../corpus/page-lifecycle/removal/ink/native/before/notebook/Lifecycle.one").as_slice(), + _ => SOURCE.as_slice(), + }; + if let Ok(intent) = serde_json::from_slice::(input) + && let Ok(prepared) = PreparedEdit::create_page(source, &intent) + { + current::current(prepared.as_bytes()); + } + if let Ok(edits) = serde_json::from_slice::>(input) + && let Ok(prepared) = PreparedEdit::pages(source, &edits) + { + current::current(prepared.as_bytes()); + } + if let Ok(pages) = serde_json::from_slice::>(input) + && let Ok(prepared) = PreparedEdit::delete_pages_permanently(source, &pages) + { + current::current(prepared.as_bytes()); + } + let mut persisted = source.to_vec(); + let mut caches = std::array::from_fn::<_, 12, _>(|_| source.to_vec()); + for step in input.chunks_exact(8).take(24) { + let actor = usize::from(step[0]) % caches.len(); + if step[1] % 3 == 0 { + caches[actor].clone_from(&persisted); + } + let source = &caches[actor]; + let store = Store::parse(source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let mut pages = document.pages().unwrap(); + let mut levels: BTreeMap<_, _> = pages + .iter() + .map(|(sid, _)| { + let space = &document.spaces[sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + let Kind::Metadata { level, .. } = view.nodes[&view.roots[&2]].kind else { + panic!() + }; + (*sid, level.unwrap_or(1)) + }) + .collect(); + let selected = pages + .get(usize::from(step[2]) % pages.len().max(1)) + .copied(); + let text = ["", "Same title", "é 🦋 東京", " spaces "][usize::from(step[3]) % 4]; + let existing = if let Some((sid, page)) = selected { + let space = &document.spaces[&sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + let titles: Vec<_> = view + .nodes + .iter() + .filter_map(|(id, node)| { + (matches!( + node.kind, + Kind::RichText { + boilerplate: false, + .. + } + ) && node.extra[0].iter().any(|field| field.id == 0x88001cb4)) + .then_some(*id) + }) + .collect(); + if step[1] & 64 != 0 { + let count = if step[3] & 128 != 0 { + pages.len() + } else { + 1 + usize::from(step[3] & 64 != 0 && pages.len() > 1) + }; + let removed: Vec<_> = (0..count) + .map(|i| pages[(usize::from(step[2]) + i) % pages.len()].0) + .collect(); + let prepared = PreparedEdit::delete_pages_permanently(source, &removed).unwrap(); + pages.retain(|page| !removed.contains(&page.0)); + for sid in removed { + levels.remove(&sid); + } + if let Some((first, _)) = pages.first() { + levels.insert(*first, 1); + } + Some((prepared, None)) + } else if step[1] & 32 != 0 { + let count = 1 + usize::from(step[3] & 128 != 0 && pages.len() > 1); + let selected: Vec<_> = (0..count) + .map(|i| pages[(usize::from(step[2]) + i) % pages.len()].0) + .collect(); + let before = (step[3] & 4 != 0) + .then_some(pages[usize::from(step[7]) % pages.len()].0) + .filter(|id| !selected.contains(id)); + let mut edits = Vec::new(); + for (ordinal, sid) in selected.iter().enumerate() { + let level = u32::from(if ordinal == 0 { step[3] } else { step[6] }) % 3 + 1; + levels.insert(*sid, level); + let edit = if step[3] & 8 == 0 { + PageEdit::set_level(*sid, level).unwrap() + } else { + let at = pages.iter().position(|p| p.0 == *sid).unwrap(); + let page = pages.remove(at); + let at = before.map_or(pages.len(), |id| { + pages.iter().position(|p| p.0 == id).unwrap() + }); + pages.insert(at, page); + PageEdit::move_to(*sid, before, level).unwrap() + }; + edits.push(edit); + } + let restored: Vec = + serde_json::from_value(serde_json::to_value(&edits).unwrap()).unwrap(); + assert_eq!(edits, restored); + let prepared = PreparedEdit::pages(source, &restored); + if levels[&pages[0].0] != 1 { + assert!(prepared.is_err()); + continue; + } + Some((prepared.unwrap(), None)) + } else if step[1] & 8 != 0 && !titles.is_empty() { + let object = titles[0]; + let Kind::RichText { text: original, .. } = &view.nodes[&object].kind else { + unreachable!() + }; + Some(( + PreparedEdit::text( + source, + sid, + object, + 0..u32::try_from(original.encode_utf16().count()).unwrap(), + text, + ) + .unwrap(), + Some((sid, object, text)), + )) + } else if step[1] & 16 != 0 { + let intent = Insertion::outline(page, 36.0, 36.0, text, "Page fuzz").unwrap(); + Some(( + PreparedEdit::insert(source, sid, &intent).unwrap(), + Some((sid, intent.text_object(), text)), + )) + } else { + None + } + } else { + None + }; + let (edit, title_update) = existing.unwrap_or_else(|| { + let before = if step[2] & 1 != 0 { + selected.map(|(sid, _)| { + let section = &document.spaces[&document.root]; + let section = §ion.revisions[§ion.contexts[&ExGuid::default()]]; + section.nodes[§ion.roots[&1]] + .children + .iter() + .map(|id| §ion.nodes[id]) + .find(|series| series.spaces.contains(&sid)) + .unwrap() + .spaces[0] + }) + } else { + None + }; + let title = (step[3] & 4 == 0).then_some(text); + let intent = PageCreation::new(before, title, "Page fuzz").unwrap(); + let restored = serde_json::from_value(serde_json::to_value(&intent).unwrap()).unwrap(); + assert_eq!(intent, restored); + let edit = PreparedEdit::create_page(source, &restored).unwrap(); + let position = before.map_or(pages.len(), |sid| { + pages.iter().position(|p| p.0 == sid).unwrap() + }); + pages.insert(position, (intent.space(), intent.object())); + levels.insert(intent.space(), 1); + ( + edit, + intent + .title_object() + .map(|object| (intent.space(), object, text)), + ) + }); + let after_store = Store::parse(edit.as_bytes()).unwrap(); + let after_index = RevisionIndex::parse(&after_store).unwrap(); + let after_document = Document::parse(&after_index).unwrap(); + assert_eq!(after_document.pages().unwrap(), pages); + let mut metadata_levels = BTreeMap::new(); + for (sid, _) in &pages { + let space = &after_document.spaces[sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + let Kind::Metadata { level, .. } = view.nodes[&view.roots[&2]].kind else { + panic!() + }; + assert_eq!(level.unwrap_or(1), levels[sid]); + let metadata = &view.nodes[&view.roots[&2]]; + let FieldValue::Bytes(guid) = metadata.extra[0] + .iter() + .find(|field| field.id == 0x1c001c30) + .unwrap() + .value + else { + panic!() + }; + assert!(metadata_levels.insert(guid, levels[sid]).is_none()); + } + let section = &after_document.spaces[&after_document.root]; + let view = §ion.revisions[§ion.contexts[&ExGuid::default()]]; + for metadata in view.nodes.values() { + if let Kind::Metadata { level, .. } = metadata.kind { + let FieldValue::Bytes(guid) = metadata.extra[0] + .iter() + .find(|field| field.id == 0x1c001c30) + .unwrap() + .value + else { + panic!() + }; + assert_eq!(metadata_levels.get(guid), Some(&level.unwrap_or(1))); + } + } + if let Some((sid, object, text)) = title_update { + let space = &after_document.spaces[&sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + assert!( + matches!(&view.nodes[&object].kind, Kind::RichText { text: actual, .. } if actual == text) + ); + } + for (sid, space) in &index.spaces { + let rid = space.labels[&(ExGuid::default(), 1)]; + assert_eq!( + format!("{:?}", index.resolve(*sid, rid).unwrap()), + format!("{:?}", after_index.resolve(*sid, rid).unwrap()) + ); + } + let before = current::current(&persisted); + let after = current::current(edit.as_bytes()); + let mut disk = disk::Disk { + visible: persisted.clone(), + durable: persisted.clone(), + operation: 0, + fail_at: (step[4] != 0).then_some(usize::from(u16::from_le_bytes([step[4], step[5]]))), + write_limit: if step[6] & 1 == 0 { 17 } else { 4096 }, + random: u64::from(step[7]) + 1, + }; + let result = edit.commit(&mut disk); + let observed = current::current(&disk.durable); + match result { + Ok(()) => assert_eq!(observed, after), + Err(error) => { + assert!(observed == before || observed == after); + match error.state { + CommitState::NotCommitted => assert_eq!(observed, before), + CommitState::Committed => assert_eq!(observed, after), + CommitState::Unknown => {} + } + } + } + persisted = disk.durable; + } +} diff --git a/fuzz/fuzz_targets/page.rs b/fuzz/fuzz_targets/page.rs index 39cc235be42d27e7d8fc73904e14ab3145e2c2e9..5da71c7db7554cd5b7973ac4297a9da9b527e0c9 100644 --- a/fuzz/fuzz_targets/page.rs +++ b/fuzz/fuzz_targets/page.rs @@ -1,239 +1,7 @@ #![no_main] use libfuzzer_sys::fuzz_target; -use onestore::{ - CommitState, ExGuid, Insertion, PageCreation, PageEdit, PreparedEdit, RevisionIndex, Store, - document::{Document, FieldValue, Kind}, -}; -use std::{collections::BTreeMap, sync::LazyLock}; -#[path = "../../crates/onestore/tests/support/current.rs"] -mod current; -#[path = "../../crates/onestore/tests/support/disk.rs"] -mod disk; +#[path = "../../crates/onestore/tests/support/page_schedule.rs"] +mod page_schedule; -static SOURCE: LazyLock> = LazyLock::new(|| { - onestore::create_section("pages.one", "Original 🦀 é 東京", "Author").unwrap() -}); - -fuzz_target!(|input: &[u8]| { - let source = match input.first().copied().unwrap_or(0) % 3 { - 1 => include_bytes!("../../corpus/page-lifecycle/page-edits/optional-cache/source/Lifecycle.one") - .as_slice(), - 2 => include_bytes!("../../corpus/page-lifecycle/page-edits/optional-cache/source-cold/notebook/Lifecycle.one") - .as_slice(), - _ => SOURCE.as_slice(), - }; - if let Ok(intent) = serde_json::from_slice::(input) - && let Ok(prepared) = PreparedEdit::create_page(source, &intent) - { - current::current(prepared.as_bytes()); - } - if let Ok(edits) = serde_json::from_slice::>(input) - && let Ok(prepared) = PreparedEdit::pages(source, &edits) - { - current::current(prepared.as_bytes()); - } - let mut persisted = source.to_vec(); - let mut caches = std::array::from_fn::<_, 12, _>(|_| source.to_vec()); - for step in input.chunks_exact(8).take(24) { - let actor = usize::from(step[0]) % caches.len(); - if step[1] % 3 == 0 { - caches[actor].clone_from(&persisted); - } - let source = &caches[actor]; - let store = Store::parse(source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - let mut pages = document.pages().unwrap(); - let mut levels: BTreeMap<_, _> = pages - .iter() - .map(|(sid, _)| { - let space = &document.spaces[sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - let Kind::Metadata { level, .. } = view.nodes[&view.roots[&2]].kind else { - panic!() - }; - (*sid, level.unwrap_or(1)) - }) - .collect(); - let (sid, page) = pages[usize::from(step[2]) % pages.len()]; - let space = &document.spaces[&sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - let text = ["", "Same title", "é 🦋 東京", " spaces "][usize::from(step[3]) % 4]; - let titles: Vec<_> = view - .nodes - .iter() - .filter_map(|(id, node)| { - (node.extra[0].iter().any(|field| field.id == 0x88001cb4) - && matches!( - node.kind, - Kind::RichText { - boilerplate: false, - .. - } - )) - .then_some(*id) - }) - .collect(); - let (edit, title_update) = if step[1] & 32 != 0 { - let count = 1 + usize::from(step[3] & 128 != 0 && pages.len() > 1); - let selected: Vec<_> = (0..count) - .map(|i| pages[(usize::from(step[2]) + i) % pages.len()].0) - .collect(); - let before = (step[3] & 4 != 0) - .then_some(pages[usize::from(step[7]) % pages.len()].0) - .filter(|id| !selected.contains(id)); - let mut edits = Vec::new(); - for (ordinal, sid) in selected.iter().enumerate() { - let level = u32::from(if ordinal == 0 { step[3] } else { step[6] }) % 3 + 1; - levels.insert(*sid, level); - let edit = if step[3] & 8 == 0 { - PageEdit::set_level(*sid, level).unwrap() - } else { - let at = pages.iter().position(|p| p.0 == *sid).unwrap(); - let page = pages.remove(at); - let at = before.map_or(pages.len(), |id| { - pages.iter().position(|p| p.0 == id).unwrap() - }); - pages.insert(at, page); - PageEdit::move_to(*sid, before, level).unwrap() - }; - edits.push(edit); - } - let restored: Vec = - serde_json::from_value(serde_json::to_value(&edits).unwrap()).unwrap(); - assert_eq!(edits, restored); - let prepared = PreparedEdit::pages(source, &restored); - if levels[&pages[0].0] != 1 { - assert!(prepared.is_err()); - continue; - } - (prepared.unwrap(), None) - } else if step[1] & 8 != 0 && !titles.is_empty() { - let object = titles[0]; - let Kind::RichText { text: original, .. } = &view.nodes[&object].kind else { - unreachable!() - }; - ( - PreparedEdit::text( - source, - sid, - object, - 0..original.encode_utf16().count() as u32, - text, - ) - .unwrap(), - Some((sid, object, text)), - ) - } else if step[1] & 16 != 0 { - let intent = Insertion::outline(page, 36.0, 36.0, text, "Page fuzz").unwrap(); - ( - PreparedEdit::insert(source, sid, &intent).unwrap(), - Some((sid, intent.text_object(), text)), - ) - } else { - let section = &document.spaces[&document.root]; - let section = §ion.revisions[§ion.contexts[&ExGuid::default()]]; - let head = section.nodes[§ion.roots[&1]] - .children - .iter() - .map(|id| §ion.nodes[id]) - .find(|series| series.spaces.contains(&sid)) - .unwrap() - .spaces[0]; - let before = (step[2] & 1 != 0).then_some(head); - let title = (step[3] & 4 == 0).then_some(text); - let intent = PageCreation::new(before, title, "Page fuzz").unwrap(); - let restored = serde_json::from_value(serde_json::to_value(&intent).unwrap()).unwrap(); - assert_eq!(intent, restored); - let edit = PreparedEdit::create_page(source, &restored).unwrap(); - let position = before.map_or(pages.len(), |sid| { - pages.iter().position(|p| p.0 == sid).unwrap() - }); - pages.insert(position, (intent.space(), intent.object())); - levels.insert(intent.space(), 1); - ( - edit, - intent - .title_object() - .map(|object| (intent.space(), object, text)), - ) - }; - let after_store = Store::parse(edit.as_bytes()).unwrap(); - let after_index = RevisionIndex::parse(&after_store).unwrap(); - let after_document = Document::parse(&after_index).unwrap(); - assert_eq!(after_document.pages().unwrap(), pages); - let mut metadata_levels = BTreeMap::new(); - for (sid, _) in &pages { - let space = &after_document.spaces[sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - let Kind::Metadata { level, .. } = view.nodes[&view.roots[&2]].kind else { - panic!() - }; - assert_eq!(level.unwrap_or(1), levels[sid]); - let metadata = &view.nodes[&view.roots[&2]]; - let FieldValue::Bytes(guid) = metadata.extra[0] - .iter() - .find(|field| field.id == 0x1c001c30) - .unwrap() - .value - else { - panic!() - }; - assert!(metadata_levels.insert(guid, levels[sid]).is_none()); - } - let section = &after_document.spaces[&after_document.root]; - let view = §ion.revisions[§ion.contexts[&ExGuid::default()]]; - for metadata in view.nodes.values() { - if let Kind::Metadata { level, .. } = metadata.kind { - let FieldValue::Bytes(guid) = metadata.extra[0] - .iter() - .find(|field| field.id == 0x1c001c30) - .unwrap() - .value - else { - panic!() - }; - assert_eq!(metadata_levels.get(guid), Some(&level.unwrap_or(1))); - } - } - if let Some((sid, object, text)) = title_update { - let space = &after_document.spaces[&sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - assert!( - matches!(&view.nodes[&object].kind, Kind::RichText { text: actual, .. } if actual == text) - ); - } - for (sid, space) in &index.spaces { - let rid = space.labels[&(ExGuid::default(), 1)]; - assert_eq!( - format!("{:?}", index.resolve(*sid, rid).unwrap()), - format!("{:?}", after_index.resolve(*sid, rid).unwrap()) - ); - } - let before = current::current(&persisted); - let after = current::current(edit.as_bytes()); - let mut disk = disk::Disk { - visible: persisted.clone(), - durable: persisted.clone(), - operation: 0, - fail_at: (step[4] != 0).then_some(usize::from(u16::from_le_bytes([step[4], step[5]]))), - write_limit: if step[6] & 1 == 0 { 17 } else { 4096 }, - random: u64::from(step[7]) + 1, - }; - let result = edit.commit(&mut disk); - let observed = current::current(&disk.durable); - match result { - Ok(()) => assert_eq!(observed, after), - Err(error) => { - assert!(observed == before || observed == after); - match error.state { - CommitState::NotCommitted => assert_eq!(observed, before), - CommitState::Committed => assert_eq!(observed, after), - CommitState::Unknown => {} - } - } - } - persisted = disk.durable; - } -}); +fuzz_target!(|input: &[u8]| page_schedule::run(input)); diff --git a/readme.md b/readme.md index 61a6fb7e90326c57524e23e776edc4eb76f9e181..db4d763d6fb9d77d3da196e9840bc02dc735732e 100644 --- a/readme.md +++ b/readme.md @@ -1 +1,6 @@ # Snowbound — Freeform note taking + +Type and draw notes on any platform, while maintaining ownership of your data. + +Snowbound implements the file and sync protocol used in 2010 Microsoft OneNote, +so notebooks are fully compatible. -- 2.54.0