authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-09 01:08:47-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-09 01:08:47-07:00
logde01c779539ae34b40906112aa468ef3a52979fa
tree57a699082086a1377e8fdb803b013db352da1e6f
parentdc3ffc4eda7a84676b6259d39f0cfb0085d8e575
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

7 files changed, 930 insertions(+), 440 deletions(-)

crates/onestore/README.md+8
...@@ -47,6 +47,7 @@ harness also accepts `--client-profile release`....@@ -47,6 +47,7 @@ harness also accepts `--client-profile release`.
47| `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode |47| `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode |
48| `PageCreation`, `PreparedEdit::create_page` | Add an empty top-level page and its section entry atomically, retaining page identities across retries |48| `PageCreation`, `PreparedEdit::create_page` | Add an empty top-level page and its section entry atomically, retaining page identities across retries |
49| `PageEdit`, `PreparedEdit::pages` | Publish explicitly selected page moves and indentation changes together, preserving page content and historical revisions |49| `PageEdit`, `PreparedEdit::pages` | Publish explicitly selected page moves and indentation changes together, preserving page content and historical revisions |
50| `PreparedEdit::delete_pages_permanently` | Remove explicit pages and their section references atomically while retaining stored revisions |
50| `create_table_of_contents` | Create ordered section entries from filenames and file identities |51| `create_table_of_contents` | Create ordered section entries from filenames and file identities |
51| `replace_property_bytes` | Append one scalar-property revision; preserve prior revisions and unrelated property values and references |52| `replace_property_bytes` | Append one scalar-property revision; preserve prior revisions and unrelated property values and references |
52| `replace_text`, `commit_text`, `commit_file_text` | Replace a UTF-16 range across ordinary text runs; publish text, run boundaries and modification time together |53| `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...@@ -92,6 +93,13 @@ pages in order. Retain the intents across retries so newly formed series keep
92their identities; `reposition(PagePosition, level)` revises their placement while93their identities; `reposition(PagePosition, level)` revises their placement while
93preserving those identities. [Native page-edit fixtures](../../corpus/page-lifecycle/page-edits/README.md)94preserving those identities. [Native page-edit fixtures](../../corpus/page-lifecycle/page-edits/README.md)
94cover individual tabs, selected and collapsed groups, nesting and promotion.95cover individual tabs, selected and collapsed groups, nesting and promotion.
96`PreparedEdit::delete_pages_permanently` removes exactly the supplied page spaces,
97including subpages only when selected explicitly. The first remaining page becomes
98top-level; other page levels and surviving content are retained. The operation
99creates no recycle-bin copies and preserves prior revisions, so it is not secure
100erasure. Duplicate, missing or non-page identities reject the entire batch;
101an empty selection leaves the file unchanged. Commit uses the exact source
102snapshot and rejects a stale prepared removal before writing.
95Insertions update child references, reference counts, modification times and automatic103Insertions update child references, reference counts, modification times and automatic
96titles atomically. Paragraphs can be nested or inserted into table cells; outline104titles atomically. Paragraphs can be nested or inserted into table cells; outline
97coordinates use points. `Insertion::with_formatting` includes nonoverlapping UTF-16105coordinates use points. `Insertion::with_formatting` includes nonoverlapping UTF-16
crates/onestore/src/commit.rs+14-1
...@@ -188,7 +188,20 @@ impl<'a> PreparedEdit<'a> {...@@ -188,7 +188,20 @@ impl<'a> PreparedEdit<'a> {
188 pub fn pages(source: &'a [u8], edits: &[crate::PageEdit]) -> Result<Self, crate::Error> {188 pub fn pages(source: &'a [u8], edits: &[crate::PageEdit]) -> Result<Self, crate::Error> {
189 Ok(Self {189 Ok(Self {
190 source,190 source,
191 written: crate::PageEdit::apply(source, edits)?,191 written: crate::page::edit_pages(source, edits, &[])?,
192 })
193 }
194
195 /// Permanently removes explicitly selected pages from the section in one transaction.
196 /// Subpages must be selected explicitly; a surviving first subpage becomes top-level.
197 /// Creates no recycle-bin copies. Prior revisions remain stored; this is not secure erasure.
198 pub fn delete_pages_permanently(
199 source: &'a [u8],
200 pages: &[crate::ExGuid],
201 ) -> Result<Self, crate::Error> {
202 Ok(Self {
203 source,
204 written: crate::page::edit_pages(source, &[], pages)?,
192 })205 })
193 }206 }
194207
crates/onestore/src/page.rs+254-204
...@@ -127,234 +127,279 @@ impl PageEdit {...@@ -127,234 +127,279 @@ impl PageEdit {
127 }127 }
128 Ok(())128 Ok(())
129 }129 }
130}
130131
131 pub(crate) fn apply(source: &[u8], edits: &[Self]) -> Result<Vec<u8>, Error> {132pub(crate) fn edit_pages(
132 let mut selected = BTreeMap::new();133 source: &[u8],
133 let mut guids = BTreeSet::new();134 edits: &[PageEdit],
134 for edit in edits {135 removals: &[ExGuid],
135 edit.validate()?;136) -> Result<Vec<u8>, Error> {
136 if selected.insert(edit.space, edit).is_some() || !guids.insert(edit.guid) {137 if !edits.is_empty() && !removals.is_empty() {
138 return Err(invalid(
139 "Choose page placement or page removal for one operation",
140 ));
141 }
142 let removed: BTreeSet<_> = removals.iter().copied().collect();
143 if removed.len() != removals.len() || removed.iter().any(|sid| sid.guid == [0; 16]) {
144 return Err(invalid("Choose distinct existing page spaces for removal"));
145 }
146 let mut selected = BTreeMap::new();
147 let mut guids = BTreeSet::new();
148 for edit in edits {
149 edit.validate()?;
150 if selected.insert(edit.space, edit).is_some() || !guids.insert(edit.guid) {
151 return Err(invalid(
152 "Use distinct page and edit identities within a batch",
153 ));
154 }
155 }
156 write_revisions(source, |index| {
157 let document = Document::parse(index)?;
158 let pages = document.pages()?;
159 let section = &document.spaces[&document.root];
160 let view = &section.revisions[&section.contexts[&ExGuid::default()]];
161 let section_id = view.roots[&1];
162 let section_node = &view.nodes[&section_id];
163 if !matches!(section_node.kind, Kind::Section { .. })
164 || section_node.extra[0]
165 .iter()
166 .any(|field| field.id == 0x88001cde)
167 {
168 return Err(invalid("Choose an editable section for page editing"));
169 }
170 let mut order = Vec::new();
171 let mut levels = BTreeMap::new();
172 let mut page_guids = BTreeMap::new();
173 for (sid, _) in &pages {
174 let page = &document.spaces[sid];
175 let page = &page.revisions[&page.contexts[&ExGuid::default()]];
176 let Some(metadata) = page.roots.get(&2).and_then(|id| page.nodes.get(id)) else {
177 return Err(invalid("Page metadata is unavailable"));
178 };
179 let Kind::Metadata { level, .. } = metadata.kind else {
180 return Err(invalid("Choose ordinary pages with page metadata"));
181 };
182 let level = level.unwrap_or(1);
183 if !(1..=3).contains(&level) || levels.insert(*sid, level).is_some() {
137 return Err(invalid(184 return Err(invalid(
138 "Use distinct page and edit identities within a batch",185 "Each page space must contain one page with a valid level",
139 ));186 ));
140 }187 }
188 if page_guids.insert(metadata_guid(metadata)?, *sid).is_some() {
189 return Err(invalid("Pages must have distinct metadata identifiers"));
190 }
191 order.push(*sid);
141 }192 }
142 write_revisions(source, |index| {193 let original_order = order.clone();
143 let document = Document::parse(index)?;194 let original_levels = levels.clone();
144 let pages = document.pages()?;195 let mut series_by_head = BTreeMap::new();
145 let section = &document.spaces[&document.root];196 let mut copies = BTreeMap::new();
146 let view = &section.revisions[&section.contexts[&ExGuid::default()]];197 let mut copy_ids = BTreeSet::new();
147 let section_id = view.roots[&1];198 for oid in &section_node.children {
148 let section_node = &view.nodes[&section_id];199 let series = &view.nodes[oid];
149 if !matches!(section_node.kind, Kind::Section { .. })200 let Some(head) = series.spaces.first() else {
150 || section_node.extra[0]201 return Err(invalid("A page series must contain at least one page"));
202 };
203 if levels.get(head) != Some(&1)
204 || series.spaces[1..]
151 .iter()205 .iter()
152 .any(|field| field.id == 0x88001cde)206 .any(|sid| levels.get(sid).is_none_or(|level| *level == 1))
153 {207 {
154 return Err(invalid("Choose an editable section for page movement"));208 return Err(invalid(
209 "A page series must start with its only top-level page",
210 ));
155 }211 }
156 let mut order = Vec::new();212 if let Some(retained) = series.spaces.iter().find(|sid| !removed.contains(sid)) {
157 let mut levels = BTreeMap::new();213 series_by_head.insert(*retained, *oid);
158 let mut page_guids = BTreeMap::new();
159 for (sid, _) in &pages {
160 let page = &document.spaces[sid];
161 let page = &page.revisions[&page.contexts[&ExGuid::default()]];
162 let Some(metadata) = page.roots.get(&2).and_then(|id| page.nodes.get(id)) else {
163 return Err(invalid("Page metadata is unavailable"));
164 };
165 let Kind::Metadata { level, .. } = metadata.kind else {
166 return Err(invalid("Choose ordinary pages with page metadata"));
167 };
168 let level = level.unwrap_or(1);
169 if !(1..=3).contains(&level) || levels.insert(*sid, level).is_some() {
170 return Err(invalid(
171 "Each page space must contain one page with a valid level",
172 ));
173 }
174 if page_guids.insert(metadata_guid(metadata)?, *sid).is_some() {
175 return Err(invalid("Pages must have distinct metadata identifiers"));
176 }
177 order.push(*sid);
178 }214 }
179 let original_order = order.clone();215 if let Some(field) = series.extra[0].iter().find(|field| field.id == 0x24003442) {
180 let original_levels = levels.clone();216 let FieldValue::Objects(ids) = &field.value else {
181 let mut series_by_head = BTreeMap::new();217 return Err(invalid("Page metadata copies must be object references"));
182 let mut copies = BTreeMap::new();
183 let mut copy_ids = BTreeSet::new();
184 for oid in &section_node.children {
185 let series = &view.nodes[oid];
186 let Some(head) = series.spaces.first() else {
187 return Err(invalid("A page series must contain at least one page"));
188 };218 };
189 if levels.get(head) != Some(&1)219 if ids.len() != series.spaces.len() {
190 || series.spaces[1..]220 return Err(invalid("Page metadata copies must match their series"));
191 .iter()
192 .any(|sid| levels.get(sid).is_none_or(|level| *level == 1))
193 {
194 return Err(invalid(
195 "A page series must start with its only top-level page",
196 ));
197 }221 }
198 series_by_head.insert(*head, *oid);222 for id in ids {
199 if let Some(field) = series.extra[0].iter().find(|field| field.id == 0x24003442) {223 if !copy_ids.insert(*id)
200 let FieldValue::Objects(ids) = &field.value else {224 || !matches!(
201 return Err(invalid("Page metadata copies must be object references"));225 view.nodes.get(id).map(|n| &n.kind),
202 };226 Some(Kind::Metadata { .. })
203 if ids.len() != series.spaces.len() {227 )
204 return Err(invalid("Page metadata copies must match their series"));228 {
229 return Err(invalid("Each page needs its own ordinary metadata copy"));
205 }230 }
206 for id in ids {231 let sid = page_guids
207 if !copy_ids.insert(*id)232 .get(&metadata_guid(&view.nodes[id])?)
208 || !matches!(233 .filter(|sid| series.spaces.contains(sid))
209 view.nodes.get(id).map(|n| &n.kind),234 .ok_or_else(|| {
210 Some(Kind::Metadata { .. })235 invalid("Metadata copy must identify a page in its series")
211 )236 })?;
212 {237 if copies.insert(*sid, *id).is_some() {
213 return Err(invalid("Each page needs its own ordinary metadata copy"));238 return Err(invalid("Each page needs its own ordinary metadata copy"));
214 }
215 let sid = page_guids
216 .get(&metadata_guid(&view.nodes[id])?)
217 .filter(|sid| series.spaces.contains(sid))
218 .ok_or_else(|| {
219 invalid("Metadata copy must identify a page in its series")
220 })?;
221 if copies.insert(*sid, *id).is_some() {
222 return Err(invalid("Each page needs its own ordinary metadata copy"));
223 }
224 }239 }
225 }240 }
226 }241 }
227 for edit in edits {242 }
228 let Some(at) = order.iter().position(|sid| *sid == edit.space) else {243 for sid in &removed {
229 return Err(invalid("The selected page is no longer in the section"));244 if !levels.contains_key(sid) {
230 };245 return Err(invalid("The selected page is no longer in the section"));
231 levels.insert(edit.space, edit.level);
232 if let PagePosition::Before(before) = edit.position {
233 order.remove(at);
234 let position = match before {
235 None => order.len(),
236 Some(before) => {
237 order.iter().position(|sid| *sid == before).ok_or_else(|| {
238 invalid("The movement anchor is no longer in the section")
239 })?
240 }
241 };
242 order.insert(position, edit.space);
243 }
244 }246 }
245 if order.first().is_some_and(|sid| levels[sid] != 1) {247 }
246 return Err(invalid(248 order.retain(|sid| !removed.contains(sid));
247 "The section's first page must have indentation level 1",249 if !removed.is_empty()
248 ));250 && let Some(first) = order.first()
251 {
252 levels.insert(*first, 1);
253 }
254 for edit in edits {
255 let Some(at) = order.iter().position(|sid| *sid == edit.space) else {
256 return Err(invalid("The selected page is no longer in the section"));
257 };
258 levels.insert(edit.space, edit.level);
259 if let PagePosition::Before(before) = edit.position {
260 order.remove(at);
261 let position = match before {
262 None => order.len(),
263 Some(before) => {
264 order.iter().position(|sid| *sid == before).ok_or_else(|| {
265 invalid("The movement anchor is no longer in the section")
266 })?
267 }
268 };
269 order.insert(position, edit.space);
249 }270 }
250 if order == original_order && levels == original_levels {271 }
251 return Ok(BTreeMap::new());272 if order.first().is_some_and(|sid| levels[sid] != 1) {
273 return Err(invalid(
274 "The section's first page must have indentation level 1",
275 ));
276 }
277 if order == original_order && levels == original_levels {
278 return Ok(BTreeMap::new());
279 }
280 let raw = index.resolve(document.root, section.contexts[&ExGuid::default()])?;
281 let mut groups: Vec<Vec<ExGuid>> = Vec::new();
282 for sid in order {
283 if levels[&sid] == 1 {
284 groups.push(Vec::new());
252 }285 }
253 let raw = index.resolve(document.root, section.contexts[&ExGuid::default()])?;286 groups.last_mut().unwrap().push(sid);
254 let mut groups: Vec<Vec<ExGuid>> = Vec::new();287 }
255 for sid in order {288 let mut changes = BTreeMap::new();
256 if levels[&sid] == 1 {289 for sid in &removed {
257 groups.push(Vec::new());290 let page = index.resolve(*sid, index.spaces[sid].labels[&(ExGuid::default(), 1)])?;
258 }291 let manifest_id = page.roots[&1];
259 groups.last_mut().unwrap().push(sid);292 let mut manifest = PropertyObject::from_object(&page.objects[&manifest_id])?;
293 if manifest.jcid != 0x60037 {
294 return Err(invalid("Choose ordinary pages for removal"));
260 }295 }
261 let mut changes = BTreeMap::new();296 manifest.bytes = properties(&[])?;
262 let mut replacements = BTreeMap::new();297 let metadata_id = page.roots[&2];
263 let mut children = Vec::new();298 let mut metadata = PropertyObject::from_object(&page.objects[&metadata_id])?;
264 for spaces in groups {299 metadata.set(&[(0x88001de9, &[])])?;
265 let head = spaces[0];300 changes.insert(
266 let old_id = series_by_head.get(&head).copied();301 *sid,
267 let (id, mut series) = if let Some(id) = old_id {302 RevisionEdit::Update(BTreeMap::from([
268 (id, PropertyObject::from_object(&raw.objects[&id])?)303 (manifest_id, manifest),
269 } else {304 (metadata_id, metadata),
270 let edit = selected305 ])),
271 .get(&head)306 );
272 .ok_or_else(|| invalid("A new series must start at an edited page"))?;307 }
273 let id = ExGuid {308 let mut replacements = BTreeMap::new();
274 guid: edit.guid,309 let mut children = Vec::new();
275 n: 1,310 for spaces in groups {
276 };311 let head = spaces[0];
277 if raw.objects.contains_key(&id) {312 let old_id = series_by_head.get(&head).copied();
278 return Err(invalid("The new page-series identity already exists"));313 let (id, mut series) = if let Some(id) = old_id {
279 }314 (id, PropertyObject::from_object(&raw.objects[&id])?)
280 (315 } else {
281 id,316 let edit = selected
282 PropertyObject {317 .get(&head)
283 jcid: 0x60008,318 .ok_or_else(|| invalid("A new series must start at an edited page"))?;
284 bytes: properties(&[(0x1c001c30, edit.guid.to_vec())])?,319 let id = ExGuid {
285 global_ids: Arc::new(BTreeMap::from([(0, edit.guid)])),320 guid: edit.guid,
286 },321 n: 1,
287 )
288 };322 };
289 children.push(id);323 if raw.objects.contains_key(&id) {
290 let membership_changed = old_id.is_none_or(|old| view.nodes[&old].spaces != spaces);324 return Err(invalid("The new page-series identity already exists"));
291 if !membership_changed325 }
292 && !spaces.iter().any(|sid| {326 (
293 levels[sid] != original_levels[sid]327 id,
294 || copies.get(sid).is_some_and(|id| {328 PropertyObject {
295 matches!(view.nodes[id].kind, Kind::Metadata { level, .. }329 jcid: 0x60008,
330 bytes: properties(&[(0x1c001c30, edit.guid.to_vec())])?,
331 global_ids: Arc::new(BTreeMap::from([(0, edit.guid)])),
332 },
333 )
334 };
335 children.push(id);
336 let membership_changed = old_id.is_none_or(|old| view.nodes[&old].spaces != spaces);
337 if !membership_changed
338 && !spaces.iter().any(|sid| {
339 levels[sid] != original_levels[sid]
340 || copies.get(sid).is_some_and(|id| {
341 matches!(view.nodes[id].kind, Kind::Metadata { level, .. }
296 if level.unwrap_or(1) != levels[sid])342 if level.unwrap_or(1) != levels[sid])
297 })343 })
298 })344 })
299 {345 {
300 continue;346 continue;
347 }
348 let mut metadata_ids = Vec::new();
349 for sid in &spaces {
350 let page =
351 index.resolve(*sid, index.spaces[sid].labels[&(ExGuid::default(), 1)])?;
352 let page_id = page.roots[&2];
353 let original = PropertyObject::from_object(&page.objects[&page_id])?;
354 if old_id.is_none() && *sid == head {
355 series.copy_property(&original, 0x18001c65)?;
301 }356 }
302 let mut metadata_ids = Vec::new();357 if levels[sid] != original_levels[sid] {
303 for sid in &spaces {358 let mut metadata = PropertyObject::from_object(&page.objects[&page_id])?;
304 let page =359 metadata.set(&[(0x14001dff, &levels[sid].to_le_bytes())])?;
305 index.resolve(*sid, index.spaces[sid].labels[&(ExGuid::default(), 1)])?;360 changes.insert(
306 let page_id = page.roots[&2];361 *sid,
307 let original = PropertyObject::from_object(&page.objects[&page_id])?;362 RevisionEdit::Update(BTreeMap::from([(page_id, metadata)])),
308 if old_id.is_none() && *sid == head {363 );
309 series.copy_property(&original, 0x18001c65)?;
310 }
311 if levels[sid] != original_levels[sid] {
312 let mut metadata = PropertyObject::from_object(&page.objects[&page_id])?;
313 metadata.set(&[(0x14001dff, &levels[sid].to_le_bytes())])?;
314 changes.insert(
315 *sid,
316 RevisionEdit::Update(BTreeMap::from([(page_id, metadata)])),
317 );
318 }
319 let copy_id = copies
320 .get(sid)
321 .copied()
322 .unwrap_or_else(|| metadata_id(*sid));
323 let mut copy = match raw.objects.get(&copy_id) {
324 Some(object) => {
325 if object.jcid != 0x20030
326 || (!copies.contains_key(sid) && copy_ids.contains(&copy_id))
327 {
328 return Err(invalid("Page metadata identities overlap"));
329 }
330 PropertyObject::from_object(object)?
331 }
332 None => original,
333 };
334 copy.set(&[(0x14001dff, &levels[sid].to_le_bytes())])?;
335 copy.reference(copy_id)?;
336 if replacements.insert(copy_id, copy).is_some() {
337 return Err(invalid("Page metadata identities overlap"));
338 }
339 metadata_ids.push(copy_id);
340 }364 }
341 if membership_changed || spaces.iter().any(|sid| !copies.contains_key(sid)) {365 let copy_id = copies
342 set_references(&mut series, 0x2c001d63, &spaces)?;366 .get(sid)
343 set_references(&mut series, 0x24003442, &metadata_ids)?;367 .copied()
344 if replacements.insert(id, series).is_some() {368 .unwrap_or_else(|| metadata_id(*sid));
345 return Err(invalid("Page-series and metadata identities overlap"));369 let mut copy = match raw.objects.get(&copy_id) {
370 Some(object) => {
371 if object.jcid != 0x20030
372 || (!copies.contains_key(sid) && copy_ids.contains(&copy_id))
373 {
374 return Err(invalid("Page metadata identities overlap"));
375 }
376 PropertyObject::from_object(object)?
346 }377 }
378 None => original,
379 };
380 copy.set(&[(0x14001dff, &levels[sid].to_le_bytes())])?;
381 copy.reference(copy_id)?;
382 if replacements.insert(copy_id, copy).is_some() {
383 return Err(invalid("Page metadata identities overlap"));
347 }384 }
385 metadata_ids.push(copy_id);
348 }386 }
349 if children != section_node.children {387 if membership_changed || spaces.iter().any(|sid| !copies.contains_key(sid)) {
350 let mut parent = PropertyObject::from_object(&raw.objects[&section_id])?;388 set_references(&mut series, 0x2c001d63, &spaces)?;
351 set_references(&mut parent, 0x24001c20, &children)?;389 set_references(&mut series, 0x24003442, &metadata_ids)?;
352 replacements.insert(section_id, parent);390 if replacements.insert(id, series).is_some() {
391 return Err(invalid("Page-series and metadata identities overlap"));
392 }
353 }393 }
354 changes.insert(document.root, RevisionEdit::Update(replacements));394 }
355 Ok(changes)395 if children != section_node.children {
356 })396 let mut parent = PropertyObject::from_object(&raw.objects[&section_id])?;
357 }397 set_references(&mut parent, 0x24001c20, &children)?;
398 replacements.insert(section_id, parent);
399 }
400 changes.insert(document.root, RevisionEdit::Update(replacements));
401 Ok(changes)
402 })
358}403}
359404
360/// An empty top-level page with stable identities and creation time.405/// An empty top-level page with stable identities and creation time.
...@@ -685,7 +730,7 @@ mod tests {...@@ -685,7 +730,7 @@ mod tests {
685 }730 }
686 }731 }
687 let written =732 let written =
688 PageEdit::apply(&source, &[PageEdit::set_level(sid, 2).unwrap()]).unwrap();733 edit_pages(&source, &[PageEdit::set_level(sid, 2).unwrap()], &[]).unwrap();
689 let store = Store::parse(&written).unwrap();734 let store = Store::parse(&written).unwrap();
690 let index = RevisionIndex::parse(&store).unwrap();735 let index = RevisionIndex::parse(&store).unwrap();
691 index.validate_current().unwrap();736 index.validate_current().unwrap();
...@@ -761,7 +806,12 @@ mod tests {...@@ -761,7 +806,12 @@ mod tests {
761 })806 })
762 .unwrap();807 .unwrap();
763 assert!(808 assert!(
764 PageEdit::apply(&invalid_source, &[PageEdit::set_level(sid, 2).unwrap()]).is_err()809 edit_pages(
810 &invalid_source,
811 &[PageEdit::set_level(sid, 2).unwrap()],
812 &[]
813 )
814 .is_err()
765 );815 );
766 }816 }
767 }817 }
...@@ -810,7 +860,7 @@ mod tests {...@@ -810,7 +860,7 @@ mod tests {
810 .map(|(i, (sid, _))| PageEdit::move_to(*sid, None, i as u32 + 1).unwrap())860 .map(|(i, (sid, _))| PageEdit::move_to(*sid, None, i as u32 + 1).unwrap())
811 .collect(),861 .collect(),
812 ] {862 ] {
813 let written = PageEdit::apply(&source, &edits).unwrap();863 let written = edit_pages(&source, &edits, &[]).unwrap();
814 let old_store = Store::parse(&source).unwrap();864 let old_store = Store::parse(&source).unwrap();
815 let new_store = Store::parse(&written).unwrap();865 let new_store = Store::parse(&written).unwrap();
816 let old = RevisionIndex::parse(&old_store).unwrap();866 let old = RevisionIndex::parse(&old_store).unwrap();
crates/onestore/tests/page_deletion.rs created+371
...@@ -0,0 +1,371 @@
1use onestore::{
2 ExGuid, FileDataReference, ObjectData, PreparedEdit, PropertySets, RevisionIndex, Store,
3 document::Document,
4};
5use std::collections::BTreeSet;
6
7const SOURCE: &[u8] =
8 include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one");
9
10fn verify(original: &[u8], written: &[u8], native: &[u8], removed: &[ExGuid]) {
11 let stores = [original, written, native].map(|bytes| Store::parse(bytes).unwrap());
12 let indexes = stores.each_ref().map(|store| {
13 assert!(store.checksum_mismatches.is_empty());
14 let index = RevisionIndex::parse(store).unwrap();
15 index.validate_current().unwrap();
16 index
17 });
18 let documents = indexes
19 .each_ref()
20 .map(|index| Document::parse(index).unwrap());
21 assert_eq!(stores[0].header.file_id, stores[1].header.file_id);
22 assert_eq!(
23 stores[1].header.transaction_count,
24 stores[0].header.transaction_count + 1
25 );
26 assert_eq!(
27 indexes[0].spaces.keys().collect::<Vec<_>>(),
28 indexes[1].spaces.keys().collect::<Vec<_>>()
29 );
30 let expected: Vec<_> = documents[0]
31 .pages()
32 .unwrap()
33 .into_iter()
34 .filter(|(sid, _)| !removed.contains(sid))
35 .collect();
36 assert_eq!(documents[1].pages().unwrap(), expected);
37 assert_eq!(documents[2].pages().unwrap(), expected);
38 assert_eq!(
39 documents[1].spaces.keys().collect::<Vec<_>>(),
40 documents[2].spaces.keys().collect::<Vec<_>>()
41 );
42 for (sid, space) in &documents[1].spaces {
43 let expected = &documents[2].spaces[sid];
44 assert_eq!(
45 space.contexts.keys().collect::<Vec<_>>(),
46 expected.contexts.keys().collect::<Vec<_>>()
47 );
48 for (context, rid) in &space.contexts {
49 let actual = &space.revisions[rid];
50 let expected = &expected.revisions[&expected.contexts[context]];
51 assert_eq!(actual.roots, expected.roots);
52 assert!(
53 serde_json::to_value(&actual.nodes).unwrap()
54 == serde_json::to_value(&expected.nodes).unwrap(),
55 "native page graph {sid}/{context}"
56 );
57 }
58 }
59 let mut payloads = BTreeSet::new();
60 for (sid, space) in &indexes[0].spaces {
61 for rid in space.revisions.keys() {
62 let before = indexes[0].resolve(*sid, *rid).unwrap();
63 let after = indexes[1].resolve(*sid, *rid).unwrap();
64 assert_eq!(
65 format!("{before:?}"),
66 format!("{after:?}"),
67 "prior revision {sid}/{rid}"
68 );
69 for object in before.objects.values() {
70 if let Some(FileDataReference::Internal(guid)) = object.file_reference().unwrap() {
71 payloads.insert(guid);
72 }
73 }
74 }
75 if removed.contains(sid) {
76 assert!(!documents[1].spaces.contains_key(sid));
77 let before = indexes[0]
78 .resolve(*sid, space.labels[&(ExGuid::default(), 1)])
79 .unwrap();
80 let after = indexes[1]
81 .resolve(*sid, indexes[1].spaces[sid].labels[&(ExGuid::default(), 1)])
82 .unwrap();
83 assert_eq!(before.roots, after.roots);
84 let ObjectData::Properties(bytes) = after.objects[&after.roots[&1]].data else {
85 panic!()
86 };
87 assert!(PropertySets::parse(bytes).unwrap().sets[0].is_empty());
88 let ObjectData::Properties(bytes) = after.objects[&after.roots[&2]].data else {
89 panic!()
90 };
91 let metadata = PropertySets::parse(bytes).unwrap();
92 assert!(metadata.sets[0].iter().any(|p| p.id == 0x88001de9));
93 let native = indexes[2]
94 .resolve(*sid, indexes[2].spaces[sid].labels[&(ExGuid::default(), 1)])
95 .unwrap();
96 assert_eq!(after.reachable().unwrap(), native.reachable().unwrap());
97 for (label, rid) in &space.labels {
98 if *label != (ExGuid::default(), 1) {
99 assert_eq!(indexes[1].spaces[sid].labels[label], *rid);
100 }
101 }
102 }
103 }
104 for guid in payloads {
105 assert_eq!(
106 stores[0].file_data(guid).unwrap(),
107 stores[1].file_data(guid).unwrap()
108 );
109 }
110}
111
112#[test]
113fn removal_matches_all_native_cases_and_retains_every_old_revision() {
114 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
115 .join("../../corpus/page-lifecycle/removal");
116 let cases: serde_json::Value =
117 serde_json::from_slice(&std::fs::read(root.join("provenance.json")).unwrap()).unwrap();
118 for (name, case) in cases.as_object().unwrap() {
119 let original =
120 std::fs::read(root.join(name).join("native/before/notebook/Lifecycle.one")).unwrap();
121 let native =
122 std::fs::read(root.join(name).join("native/after/notebook/Lifecycle.one")).unwrap();
123 let store = Store::parse(&original).unwrap();
124 let index = RevisionIndex::parse(&store).unwrap();
125 let document = Document::parse(&index).unwrap();
126 let pages = document.pages().unwrap();
127 let selected: Vec<usize> =
128 serde_json::from_value(case["request"]["selected"].clone()).unwrap();
129 let removed: Vec<_> = selected
130 .into_iter()
131 .map(|ordinal| pages[ordinal].0)
132 .collect();
133 let prepared = PreparedEdit::delete_pages_permanently(&original, &removed).unwrap();
134 if let Some(output) = std::env::var_os("ONESTORE_PAGE_REMOVAL_OUTPUT") {
135 let output = std::path::Path::new(&output);
136 assert!(output.is_absolute());
137 std::fs::create_dir_all(output).unwrap();
138 let case = output.join(name);
139 std::fs::create_dir(&case).unwrap();
140 std::fs::write(case.join("Lifecycle.one"), prepared.as_bytes()).unwrap();
141 }
142 verify(&original, prepared.as_bytes(), &native, &removed);
143 assert!(PreparedEdit::delete_pages_permanently(prepared.as_bytes(), &removed).is_err());
144 }
145}
146
147#[test]
148fn removal_rejects_duplicate_missing_and_non_page_spaces_without_publication() {
149 let store = Store::parse(SOURCE).unwrap();
150 let index = RevisionIndex::parse(&store).unwrap();
151 let document = Document::parse(&index).unwrap();
152 let pages = document.pages().unwrap();
153 for selected in [
154 vec![pages[0].0, pages[0].0],
155 vec![index.root],
156 vec![ExGuid::default()],
157 vec![ExGuid {
158 guid: [97; 16],
159 n: 1,
160 }],
161 vec![pages[0].0, index.root],
162 ] {
163 assert!(PreparedEdit::delete_pages_permanently(SOURCE, &selected).is_err());
164 }
165 assert_eq!(
166 PreparedEdit::delete_pages_permanently(SOURCE, &[])
167 .unwrap()
168 .as_bytes(),
169 SOURCE
170 );
171 let written = PreparedEdit::delete_pages_permanently(SOURCE, &[pages[0].0]).unwrap();
172 assert!(
173 PreparedEdit::delete_pages_permanently(written.as_bytes(), &[pages[1].0, pages[0].0])
174 .is_err()
175 );
176}
177
178use page_schedule::{current, disk};
179
180#[test]
181fn tombstones_and_first_page_promotion_survive_each_storage_interruption() {
182 let leading = include_bytes!(
183 "../../../corpus/page-lifecycle/removal/leading-parent/native/before/notebook/Lifecycle.one"
184 );
185 for (source, all) in [(leading.as_slice(), false), (SOURCE, true)] {
186 let store = Store::parse(source).unwrap();
187 let index = RevisionIndex::parse(&store).unwrap();
188 let pages = Document::parse(&index).unwrap().pages().unwrap();
189 let removed: Vec<_> = pages
190 .iter()
191 .take(if all { pages.len() } else { 1 })
192 .map(|page| page.0)
193 .collect();
194 let prepared = PreparedEdit::delete_pages_permanently(source, &removed).unwrap();
195 let old = current::current(source);
196 let new = current::current(prepared.as_bytes());
197 assert_ne!(old, new);
198 assert_eq!(
199 old.keys().collect::<Vec<_>>(),
200 new.keys().collect::<Vec<_>>()
201 );
202 for write_limit in [1, 17, 4096] {
203 let mut complete = disk::Disk {
204 visible: source.to_vec(),
205 durable: source.to_vec(),
206 operation: 0,
207 fail_at: None,
208 write_limit,
209 random: 1997,
210 };
211 prepared.commit(&mut complete).unwrap();
212 assert_eq!(complete.durable, prepared.as_bytes());
213 for fail_at in 1..=complete.operation {
214 let mut interrupted = disk::Disk {
215 visible: source.to_vec(),
216 durable: source.to_vec(),
217 operation: 0,
218 fail_at: Some(fail_at),
219 write_limit,
220 random: 1997 + u64::try_from(fail_at).unwrap(),
221 };
222 let error = prepared.commit(&mut interrupted).unwrap_err();
223 let observed = current::current(&interrupted.durable);
224 assert!(
225 observed == old || observed == new,
226 "{all}:{write_limit}:{fail_at}"
227 );
228 match error.state {
229 onestore::CommitState::NotCommitted => assert_eq!(observed, old),
230 onestore::CommitState::Committed => assert_eq!(observed, new),
231 onestore::CommitState::Unknown => {}
232 }
233 }
234 }
235 }
236}
237
238#[test]
239fn stale_removal_cannot_delete_a_newer_page_edit() {
240 let store = Store::parse(SOURCE).unwrap();
241 let index = RevisionIndex::parse(&store).unwrap();
242 let document = Document::parse(&index).unwrap();
243 let sid = document.pages().unwrap()[3].0;
244 let space = &document.spaces[&sid];
245 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
246 let text = revision
247 .nodes
248 .iter()
249 .find_map(|(id, node)| match &node.kind {
250 onestore::document::Kind::RichText {
251 text,
252 boilerplate: false,
253 ..
254 } if !text.is_empty() => Some(*id),
255 _ => None,
256 })
257 .unwrap();
258 let removal = PreparedEdit::delete_pages_permanently(SOURCE, &[sid]).unwrap();
259 let remote = onestore::replace_text(SOURCE, sid, text, 0..0, "New remote content ").unwrap();
260 let mut disk = disk::Disk {
261 visible: remote.clone(),
262 durable: remote.clone(),
263 operation: 0,
264 fail_at: None,
265 write_limit: 17,
266 random: 1999,
267 };
268 let error = removal.commit(&mut disk).unwrap_err();
269 assert_eq!(error.state, onestore::CommitState::NotCommitted);
270 assert_eq!(error.error.kind(), std::io::ErrorKind::ResourceBusy);
271 assert_eq!(disk.visible, remote);
272 assert_eq!(disk.durable, remote);
273}
274
275#[path = "support/page_schedule.rs"]
276mod page_schedule;
277
278#[test]
279fn twelve_client_page_schedules_include_removal_empty_sections_and_stale_edits() {
280 page_schedule::run(&[
281 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,
282 128, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0,
283 ]);
284 for seed in 1_u64..=32 {
285 let mut random = seed * 2003;
286 let mut input = [0; 192];
287 for byte in &mut input {
288 random ^= random << 13;
289 random ^= random >> 7;
290 random ^= random << 17;
291 *byte = random.to_le_bytes()[0];
292 }
293 page_schedule::run(&input);
294 }
295}
296
297#[test]
298fn external_payloads_and_their_historical_references_survive_file_removal() {
299 let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
300 .join("../../corpus/native-external-assets/notebook");
301 let original = std::fs::read(fixture.join("synthetic.one")).unwrap();
302 let store = Store::parse(&original).unwrap();
303 let index = RevisionIndex::parse(&store).unwrap();
304 let pages = Document::parse(&index).unwrap().pages().unwrap();
305 assert_eq!(pages.len(), 3);
306 let selected: Vec<_> = pages.iter().map(|page| page.0).collect();
307 let prepared = PreparedEdit::delete_pages_permanently(&original, &selected).unwrap();
308 let output = std::env::var_os("ONESTORE_PAGE_REMOVAL_EXTERNAL_OUTPUT");
309 let root = output
310 .as_ref()
311 .map(std::path::PathBuf::from)
312 .unwrap_or_else(|| {
313 std::env::temp_dir().join(format!(
314 "onestore-page-removal-{}-{}",
315 std::process::id(),
316 std::time::SystemTime::now()
317 .duration_since(std::time::UNIX_EPOCH)
318 .unwrap()
319 .as_nanos()
320 ))
321 });
322 assert!(root.is_absolute());
323 std::fs::create_dir(&root).unwrap();
324 let files = root.join("synthetic_onefiles");
325 std::fs::create_dir(&files).unwrap();
326 let mut payloads = std::collections::BTreeMap::new();
327 for entry in std::fs::read_dir(fixture.join("synthetic_onefiles")).unwrap() {
328 let entry = entry.unwrap();
329 let bytes = std::fs::read(entry.path()).unwrap();
330 std::fs::write(files.join(entry.file_name()), &bytes).unwrap();
331 payloads.insert(entry.file_name(), bytes);
332 }
333 assert_eq!(payloads.len(), 3);
334 let path = root.join("synthetic.one");
335 std::fs::write(&path, &original).unwrap();
336 prepared.commit_file(&path).unwrap();
337 let written = onestore::read_file(&path).unwrap();
338 assert_eq!(written, prepared.as_bytes());
339 let current_store = Store::parse(&written).unwrap();
340 let current_index = RevisionIndex::parse(&current_store).unwrap();
341 current_index.validate_current().unwrap();
342 assert!(
343 Document::parse(&current_index)
344 .unwrap()
345 .pages()
346 .unwrap()
347 .is_empty()
348 );
349 let mut external = BTreeSet::new();
350 for (sid, space) in &index.spaces {
351 for rid in space.revisions.keys() {
352 let before = index.resolve(*sid, *rid).unwrap();
353 let after = current_index.resolve(*sid, *rid).unwrap();
354 assert_eq!(format!("{before:?}"), format!("{after:?}"));
355 for object in before.objects.values() {
356 if let Some(FileDataReference::External(reference)) =
357 object.file_reference().unwrap()
358 {
359 external.insert(reference);
360 }
361 }
362 }
363 }
364 assert_eq!(external.len(), 3);
365 for (name, bytes) in payloads {
366 assert_eq!(std::fs::read(files.join(name)).unwrap(), bytes);
367 }
368 if output.is_none() {
369 std::fs::remove_dir_all(root).unwrap();
370 }
371}
crates/onestore/tests/support/page_schedule.rs created+275
...@@ -0,0 +1,275 @@
1use onestore::{
2 CommitState, ExGuid, Insertion, PageCreation, PageEdit, PreparedEdit, RevisionIndex, Store,
3 document::{Document, FieldValue, Kind},
4};
5use std::{collections::BTreeMap, sync::LazyLock};
6
7#[path = "current.rs"]
8pub(crate) mod current;
9#[path = "disk.rs"]
10pub(crate) mod disk;
11
12static SOURCE: LazyLock<Vec<u8>> = LazyLock::new(|| {
13 onestore::create_section("pages.one", "Original 🦀 é 東京", "Author").unwrap()
14});
15
16pub fn run(input: &[u8]) {
17 let source = match input.first().copied().unwrap_or(0) % 5 {
18 1 => include_bytes!("../../../../corpus/page-lifecycle/page-edits/optional-cache/source/Lifecycle.one")
19 .as_slice(),
20 2 => include_bytes!("../../../../corpus/page-lifecycle/page-edits/optional-cache/source-cold/notebook/Lifecycle.one")
21 .as_slice(),
22 3 => include_bytes!("../../../../corpus/page-lifecycle/removal/features/native/before/notebook/Lifecycle.one").as_slice(),
23 4 => include_bytes!("../../../../corpus/page-lifecycle/removal/ink/native/before/notebook/Lifecycle.one").as_slice(),
24 _ => SOURCE.as_slice(),
25 };
26 if let Ok(intent) = serde_json::from_slice::<PageCreation>(input)
27 && let Ok(prepared) = PreparedEdit::create_page(source, &intent)
28 {
29 current::current(prepared.as_bytes());
30 }
31 if let Ok(edits) = serde_json::from_slice::<Vec<PageEdit>>(input)
32 && let Ok(prepared) = PreparedEdit::pages(source, &edits)
33 {
34 current::current(prepared.as_bytes());
35 }
36 if let Ok(pages) = serde_json::from_slice::<Vec<ExGuid>>(input)
37 && let Ok(prepared) = PreparedEdit::delete_pages_permanently(source, &pages)
38 {
39 current::current(prepared.as_bytes());
40 }
41 let mut persisted = source.to_vec();
42 let mut caches = std::array::from_fn::<_, 12, _>(|_| source.to_vec());
43 for step in input.chunks_exact(8).take(24) {
44 let actor = usize::from(step[0]) % caches.len();
45 if step[1] % 3 == 0 {
46 caches[actor].clone_from(&persisted);
47 }
48 let source = &caches[actor];
49 let store = Store::parse(source).unwrap();
50 let index = RevisionIndex::parse(&store).unwrap();
51 let document = Document::parse(&index).unwrap();
52 let mut pages = document.pages().unwrap();
53 let mut levels: BTreeMap<_, _> = pages
54 .iter()
55 .map(|(sid, _)| {
56 let space = &document.spaces[sid];
57 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
58 let Kind::Metadata { level, .. } = view.nodes[&view.roots[&2]].kind else {
59 panic!()
60 };
61 (*sid, level.unwrap_or(1))
62 })
63 .collect();
64 let selected = pages
65 .get(usize::from(step[2]) % pages.len().max(1))
66 .copied();
67 let text = ["", "Same title", "é 🦋 東京", " spaces "][usize::from(step[3]) % 4];
68 let existing = if let Some((sid, page)) = selected {
69 let space = &document.spaces[&sid];
70 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
71 let titles: Vec<_> = view
72 .nodes
73 .iter()
74 .filter_map(|(id, node)| {
75 (matches!(
76 node.kind,
77 Kind::RichText {
78 boilerplate: false,
79 ..
80 }
81 ) && node.extra[0].iter().any(|field| field.id == 0x88001cb4))
82 .then_some(*id)
83 })
84 .collect();
85 if step[1] & 64 != 0 {
86 let count = if step[3] & 128 != 0 {
87 pages.len()
88 } else {
89 1 + usize::from(step[3] & 64 != 0 && pages.len() > 1)
90 };
91 let removed: Vec<_> = (0..count)
92 .map(|i| pages[(usize::from(step[2]) + i) % pages.len()].0)
93 .collect();
94 let prepared = PreparedEdit::delete_pages_permanently(source, &removed).unwrap();
95 pages.retain(|page| !removed.contains(&page.0));
96 for sid in removed {
97 levels.remove(&sid);
98 }
99 if let Some((first, _)) = pages.first() {
100 levels.insert(*first, 1);
101 }
102 Some((prepared, None))
103 } else if step[1] & 32 != 0 {
104 let count = 1 + usize::from(step[3] & 128 != 0 && pages.len() > 1);
105 let selected: Vec<_> = (0..count)
106 .map(|i| pages[(usize::from(step[2]) + i) % pages.len()].0)
107 .collect();
108 let before = (step[3] & 4 != 0)
109 .then_some(pages[usize::from(step[7]) % pages.len()].0)
110 .filter(|id| !selected.contains(id));
111 let mut edits = Vec::new();
112 for (ordinal, sid) in selected.iter().enumerate() {
113 let level = u32::from(if ordinal == 0 { step[3] } else { step[6] }) % 3 + 1;
114 levels.insert(*sid, level);
115 let edit = if step[3] & 8 == 0 {
116 PageEdit::set_level(*sid, level).unwrap()
117 } else {
118 let at = pages.iter().position(|p| p.0 == *sid).unwrap();
119 let page = pages.remove(at);
120 let at = before.map_or(pages.len(), |id| {
121 pages.iter().position(|p| p.0 == id).unwrap()
122 });
123 pages.insert(at, page);
124 PageEdit::move_to(*sid, before, level).unwrap()
125 };
126 edits.push(edit);
127 }
128 let restored: Vec<PageEdit> =
129 serde_json::from_value(serde_json::to_value(&edits).unwrap()).unwrap();
130 assert_eq!(edits, restored);
131 let prepared = PreparedEdit::pages(source, &restored);
132 if levels[&pages[0].0] != 1 {
133 assert!(prepared.is_err());
134 continue;
135 }
136 Some((prepared.unwrap(), None))
137 } else if step[1] & 8 != 0 && !titles.is_empty() {
138 let object = titles[0];
139 let Kind::RichText { text: original, .. } = &view.nodes[&object].kind else {
140 unreachable!()
141 };
142 Some((
143 PreparedEdit::text(
144 source,
145 sid,
146 object,
147 0..u32::try_from(original.encode_utf16().count()).unwrap(),
148 text,
149 )
150 .unwrap(),
151 Some((sid, object, text)),
152 ))
153 } else if step[1] & 16 != 0 {
154 let intent = Insertion::outline(page, 36.0, 36.0, text, "Page fuzz").unwrap();
155 Some((
156 PreparedEdit::insert(source, sid, &intent).unwrap(),
157 Some((sid, intent.text_object(), text)),
158 ))
159 } else {
160 None
161 }
162 } else {
163 None
164 };
165 let (edit, title_update) = existing.unwrap_or_else(|| {
166 let before = if step[2] & 1 != 0 {
167 selected.map(|(sid, _)| {
168 let section = &document.spaces[&document.root];
169 let section = &section.revisions[&section.contexts[&ExGuid::default()]];
170 section.nodes[&section.roots[&1]]
171 .children
172 .iter()
173 .map(|id| &section.nodes[id])
174 .find(|series| series.spaces.contains(&sid))
175 .unwrap()
176 .spaces[0]
177 })
178 } else {
179 None
180 };
181 let title = (step[3] & 4 == 0).then_some(text);
182 let intent = PageCreation::new(before, title, "Page fuzz").unwrap();
183 let restored = serde_json::from_value(serde_json::to_value(&intent).unwrap()).unwrap();
184 assert_eq!(intent, restored);
185 let edit = PreparedEdit::create_page(source, &restored).unwrap();
186 let position = before.map_or(pages.len(), |sid| {
187 pages.iter().position(|p| p.0 == sid).unwrap()
188 });
189 pages.insert(position, (intent.space(), intent.object()));
190 levels.insert(intent.space(), 1);
191 (
192 edit,
193 intent
194 .title_object()
195 .map(|object| (intent.space(), object, text)),
196 )
197 });
198 let after_store = Store::parse(edit.as_bytes()).unwrap();
199 let after_index = RevisionIndex::parse(&after_store).unwrap();
200 let after_document = Document::parse(&after_index).unwrap();
201 assert_eq!(after_document.pages().unwrap(), pages);
202 let mut metadata_levels = BTreeMap::new();
203 for (sid, _) in &pages {
204 let space = &after_document.spaces[sid];
205 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
206 let Kind::Metadata { level, .. } = view.nodes[&view.roots[&2]].kind else {
207 panic!()
208 };
209 assert_eq!(level.unwrap_or(1), levels[sid]);
210 let metadata = &view.nodes[&view.roots[&2]];
211 let FieldValue::Bytes(guid) = metadata.extra[0]
212 .iter()
213 .find(|field| field.id == 0x1c001c30)
214 .unwrap()
215 .value
216 else {
217 panic!()
218 };
219 assert!(metadata_levels.insert(guid, levels[sid]).is_none());
220 }
221 let section = &after_document.spaces[&after_document.root];
222 let view = &section.revisions[&section.contexts[&ExGuid::default()]];
223 for metadata in view.nodes.values() {
224 if let Kind::Metadata { level, .. } = metadata.kind {
225 let FieldValue::Bytes(guid) = metadata.extra[0]
226 .iter()
227 .find(|field| field.id == 0x1c001c30)
228 .unwrap()
229 .value
230 else {
231 panic!()
232 };
233 assert_eq!(metadata_levels.get(guid), Some(&level.unwrap_or(1)));
234 }
235 }
236 if let Some((sid, object, text)) = title_update {
237 let space = &after_document.spaces[&sid];
238 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
239 assert!(
240 matches!(&view.nodes[&object].kind, Kind::RichText { text: actual, .. } if actual == text)
241 );
242 }
243 for (sid, space) in &index.spaces {
244 let rid = space.labels[&(ExGuid::default(), 1)];
245 assert_eq!(
246 format!("{:?}", index.resolve(*sid, rid).unwrap()),
247 format!("{:?}", after_index.resolve(*sid, rid).unwrap())
248 );
249 }
250 let before = current::current(&persisted);
251 let after = current::current(edit.as_bytes());
252 let mut disk = disk::Disk {
253 visible: persisted.clone(),
254 durable: persisted.clone(),
255 operation: 0,
256 fail_at: (step[4] != 0).then_some(usize::from(u16::from_le_bytes([step[4], step[5]]))),
257 write_limit: if step[6] & 1 == 0 { 17 } else { 4096 },
258 random: u64::from(step[7]) + 1,
259 };
260 let result = edit.commit(&mut disk);
261 let observed = current::current(&disk.durable);
262 match result {
263 Ok(()) => assert_eq!(observed, after),
264 Err(error) => {
265 assert!(observed == before || observed == after);
266 match error.state {
267 CommitState::NotCommitted => assert_eq!(observed, before),
268 CommitState::Committed => assert_eq!(observed, after),
269 CommitState::Unknown => {}
270 }
271 }
272 }
273 persisted = disk.durable;
274 }
275}
fuzz/fuzz_targets/page.rs+3-235
...@@ -1,239 +1,7 @@...@@ -1,239 +1,7 @@
1#![no_main]1#![no_main]
2use libfuzzer_sys::fuzz_target;2use libfuzzer_sys::fuzz_target;
3use onestore::{
4 CommitState, ExGuid, Insertion, PageCreation, PageEdit, PreparedEdit, RevisionIndex, Store,
5 document::{Document, FieldValue, Kind},
6};
7use std::{collections::BTreeMap, sync::LazyLock};
83
9#[path = "../../crates/onestore/tests/support/current.rs"]4#[path = "../../crates/onestore/tests/support/page_schedule.rs"]
10mod current;5mod page_schedule;
11#[path = "../../crates/onestore/tests/support/disk.rs"]
12mod disk;
136
14static SOURCE: LazyLock<Vec<u8>> = LazyLock::new(|| {7fuzz_target!(|input: &[u8]| page_schedule::run(input));
15 onestore::create_section("pages.one", "Original 🦀 é 東京", "Author").unwrap()
16});
17
18fuzz_target!(|input: &[u8]| {
19 let source = match input.first().copied().unwrap_or(0) % 3 {
20 1 => include_bytes!("../../corpus/page-lifecycle/page-edits/optional-cache/source/Lifecycle.one")
21 .as_slice(),
22 2 => include_bytes!("../../corpus/page-lifecycle/page-edits/optional-cache/source-cold/notebook/Lifecycle.one")
23 .as_slice(),
24 _ => SOURCE.as_slice(),
25 };
26 if let Ok(intent) = serde_json::from_slice::<PageCreation>(input)
27 && let Ok(prepared) = PreparedEdit::create_page(source, &intent)
28 {
29 current::current(prepared.as_bytes());
30 }
31 if let Ok(edits) = serde_json::from_slice::<Vec<PageEdit>>(input)
32 && let Ok(prepared) = PreparedEdit::pages(source, &edits)
33 {
34 current::current(prepared.as_bytes());
35 }
36 let mut persisted = source.to_vec();
37 let mut caches = std::array::from_fn::<_, 12, _>(|_| source.to_vec());
38 for step in input.chunks_exact(8).take(24) {
39 let actor = usize::from(step[0]) % caches.len();
40 if step[1] % 3 == 0 {
41 caches[actor].clone_from(&persisted);
42 }
43 let source = &caches[actor];
44 let store = Store::parse(source).unwrap();
45 let index = RevisionIndex::parse(&store).unwrap();
46 let document = Document::parse(&index).unwrap();
47 let mut pages = document.pages().unwrap();
48 let mut levels: BTreeMap<_, _> = pages
49 .iter()
50 .map(|(sid, _)| {
51 let space = &document.spaces[sid];
52 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
53 let Kind::Metadata { level, .. } = view.nodes[&view.roots[&2]].kind else {
54 panic!()
55 };
56 (*sid, level.unwrap_or(1))
57 })
58 .collect();
59 let (sid, page) = pages[usize::from(step[2]) % pages.len()];
60 let space = &document.spaces[&sid];
61 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
62 let text = ["", "Same title", "é 🦋 東京", " spaces "][usize::from(step[3]) % 4];
63 let titles: Vec<_> = view
64 .nodes
65 .iter()
66 .filter_map(|(id, node)| {
67 (node.extra[0].iter().any(|field| field.id == 0x88001cb4)
68 && matches!(
69 node.kind,
70 Kind::RichText {
71 boilerplate: false,
72 ..
73 }
74 ))
75 .then_some(*id)
76 })
77 .collect();
78 let (edit, title_update) = if step[1] & 32 != 0 {
79 let count = 1 + usize::from(step[3] & 128 != 0 && pages.len() > 1);
80 let selected: Vec<_> = (0..count)
81 .map(|i| pages[(usize::from(step[2]) + i) % pages.len()].0)
82 .collect();
83 let before = (step[3] & 4 != 0)
84 .then_some(pages[usize::from(step[7]) % pages.len()].0)
85 .filter(|id| !selected.contains(id));
86 let mut edits = Vec::new();
87 for (ordinal, sid) in selected.iter().enumerate() {
88 let level = u32::from(if ordinal == 0 { step[3] } else { step[6] }) % 3 + 1;
89 levels.insert(*sid, level);
90 let edit = if step[3] & 8 == 0 {
91 PageEdit::set_level(*sid, level).unwrap()
92 } else {
93 let at = pages.iter().position(|p| p.0 == *sid).unwrap();
94 let page = pages.remove(at);
95 let at = before.map_or(pages.len(), |id| {
96 pages.iter().position(|p| p.0 == id).unwrap()
97 });
98 pages.insert(at, page);
99 PageEdit::move_to(*sid, before, level).unwrap()
100 };
101 edits.push(edit);
102 }
103 let restored: Vec<PageEdit> =
104 serde_json::from_value(serde_json::to_value(&edits).unwrap()).unwrap();
105 assert_eq!(edits, restored);
106 let prepared = PreparedEdit::pages(source, &restored);
107 if levels[&pages[0].0] != 1 {
108 assert!(prepared.is_err());
109 continue;
110 }
111 (prepared.unwrap(), None)
112 } else if step[1] & 8 != 0 && !titles.is_empty() {
113 let object = titles[0];
114 let Kind::RichText { text: original, .. } = &view.nodes[&object].kind else {
115 unreachable!()
116 };
117 (
118 PreparedEdit::text(
119 source,
120 sid,
121 object,
122 0..original.encode_utf16().count() as u32,
123 text,
124 )
125 .unwrap(),
126 Some((sid, object, text)),
127 )
128 } else if step[1] & 16 != 0 {
129 let intent = Insertion::outline(page, 36.0, 36.0, text, "Page fuzz").unwrap();
130 (
131 PreparedEdit::insert(source, sid, &intent).unwrap(),
132 Some((sid, intent.text_object(), text)),
133 )
134 } else {
135 let section = &document.spaces[&document.root];
136 let section = &section.revisions[&section.contexts[&ExGuid::default()]];
137 let head = section.nodes[&section.roots[&1]]
138 .children
139 .iter()
140 .map(|id| &section.nodes[id])
141 .find(|series| series.spaces.contains(&sid))
142 .unwrap()
143 .spaces[0];
144 let before = (step[2] & 1 != 0).then_some(head);
145 let title = (step[3] & 4 == 0).then_some(text);
146 let intent = PageCreation::new(before, title, "Page fuzz").unwrap();
147 let restored = serde_json::from_value(serde_json::to_value(&intent).unwrap()).unwrap();
148 assert_eq!(intent, restored);
149 let edit = PreparedEdit::create_page(source, &restored).unwrap();
150 let position = before.map_or(pages.len(), |sid| {
151 pages.iter().position(|p| p.0 == sid).unwrap()
152 });
153 pages.insert(position, (intent.space(), intent.object()));
154 levels.insert(intent.space(), 1);
155 (
156 edit,
157 intent
158 .title_object()
159 .map(|object| (intent.space(), object, text)),
160 )
161 };
162 let after_store = Store::parse(edit.as_bytes()).unwrap();
163 let after_index = RevisionIndex::parse(&after_store).unwrap();
164 let after_document = Document::parse(&after_index).unwrap();
165 assert_eq!(after_document.pages().unwrap(), pages);
166 let mut metadata_levels = BTreeMap::new();
167 for (sid, _) in &pages {
168 let space = &after_document.spaces[sid];
169 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
170 let Kind::Metadata { level, .. } = view.nodes[&view.roots[&2]].kind else {
171 panic!()
172 };
173 assert_eq!(level.unwrap_or(1), levels[sid]);
174 let metadata = &view.nodes[&view.roots[&2]];
175 let FieldValue::Bytes(guid) = metadata.extra[0]
176 .iter()
177 .find(|field| field.id == 0x1c001c30)
178 .unwrap()
179 .value
180 else {
181 panic!()
182 };
183 assert!(metadata_levels.insert(guid, levels[sid]).is_none());
184 }
185 let section = &after_document.spaces[&after_document.root];
186 let view = &section.revisions[&section.contexts[&ExGuid::default()]];
187 for metadata in view.nodes.values() {
188 if let Kind::Metadata { level, .. } = metadata.kind {
189 let FieldValue::Bytes(guid) = metadata.extra[0]
190 .iter()
191 .find(|field| field.id == 0x1c001c30)
192 .unwrap()
193 .value
194 else {
195 panic!()
196 };
197 assert_eq!(metadata_levels.get(guid), Some(&level.unwrap_or(1)));
198 }
199 }
200 if let Some((sid, object, text)) = title_update {
201 let space = &after_document.spaces[&sid];
202 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
203 assert!(
204 matches!(&view.nodes[&object].kind, Kind::RichText { text: actual, .. } if actual == text)
205 );
206 }
207 for (sid, space) in &index.spaces {
208 let rid = space.labels[&(ExGuid::default(), 1)];
209 assert_eq!(
210 format!("{:?}", index.resolve(*sid, rid).unwrap()),
211 format!("{:?}", after_index.resolve(*sid, rid).unwrap())
212 );
213 }
214 let before = current::current(&persisted);
215 let after = current::current(edit.as_bytes());
216 let mut disk = disk::Disk {
217 visible: persisted.clone(),
218 durable: persisted.clone(),
219 operation: 0,
220 fail_at: (step[4] != 0).then_some(usize::from(u16::from_le_bytes([step[4], step[5]]))),
221 write_limit: if step[6] & 1 == 0 { 17 } else { 4096 },
222 random: u64::from(step[7]) + 1,
223 };
224 let result = edit.commit(&mut disk);
225 let observed = current::current(&disk.durable);
226 match result {
227 Ok(()) => assert_eq!(observed, after),
228 Err(error) => {
229 assert!(observed == before || observed == after);
230 match error.state {
231 CommitState::NotCommitted => assert_eq!(observed, before),
232 CommitState::Committed => assert_eq!(observed, after),
233 CommitState::Unknown => {}
234 }
235 }
236 }
237 persisted = disk.durable;
238 }
239});
readme.md+5
...@@ -1 +1,6 @@...@@ -1 +1,6 @@
1# Snowbound — Freeform note taking1# Snowbound — Freeform note taking
2
3Type and draw notes on any platform, while maintaining ownership of your data.
4
5Snowbound implements the file and sync protocol used in 2010 Microsoft OneNote,
6so notebooks are fully compatible.