| author | |
| committer | |
| log | de01c779539ae34b40906112aa468ef3a52979fa |
| tree | 57a699082086a1377e8fdb803b013db352da1e6f |
| parent | dc3ffc4eda7a84676b6259d39f0cfb0085d8e575 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.17 files changed, 930 insertions(+), 440 deletions(-)
crates/onestore/README.md+8| ... | ... | @@ -47,6 +47,7 @@ harness also accepts `--client-profile release`. |
| 47 | 47 | | `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode | |
| 48 | 48 | | `PageCreation`, `PreparedEdit::create_page` | Add an empty top-level page and its section entry atomically, retaining page identities across retries | |
| 49 | 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 | 51 | | `create_table_of_contents` | Create ordered section entries from filenames and file identities | |
| 51 | 52 | | `replace_property_bytes` | Append one scalar-property revision; preserve prior revisions and unrelated property values and references | |
| 52 | 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 | 93 | their identities; `reposition(PagePosition, level)` revises their placement while |
| 93 | 94 | preserving those identities. [Native page-edit fixtures](../../corpus/page-lifecycle/page-edits/README.md) |
| 94 | 95 | cover individual tabs, selected and collapsed groups, nesting and promotion. |
| 96 | `PreparedEdit::delete_pages_permanently` removes exactly the supplied page spaces, | |
| 97 | including subpages only when selected explicitly. The first remaining page becomes | |
| 98 | top-level; other page levels and surviving content are retained. The operation | |
| 99 | creates no recycle-bin copies and preserves prior revisions, so it is not secure | |
| 100 | erasure. Duplicate, missing or non-page identities reject the entire batch; | |
| 101 | an empty selection leaves the file unchanged. Commit uses the exact source | |
| 102 | snapshot and rejects a stale prepared removal before writing. | |
| 95 | 103 | Insertions update child references, reference counts, modification times and automatic |
| 96 | 104 | titles atomically. Paragraphs can be nested or inserted into table cells; outline |
| 97 | 105 | coordinates 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 | 188 | pub fn pages(source: &'a [u8], edits: &[crate::PageEdit]) -> Result<Self, crate::Error> { |
| 189 | 189 | Ok(Self { |
| 190 | 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 | } |
| 194 | 207 |
crates/onestore/src/page.rs+254-204| ... | ... | @@ -127,234 +127,279 @@ impl PageEdit { |
| 127 | 127 | } |
| 128 | 128 | Ok(()) |
| 129 | 129 | } |
| 130 | } | |
| 130 | 131 | |
| 131 | pub(crate) fn apply(source: &[u8], edits: &[Self]) -> Result<Vec<u8>, Error> { | |
| 132 | let mut selected = BTreeMap::new(); | |
| 133 | let mut guids = BTreeSet::new(); | |
| 134 | for edit in edits { | |
| 135 | edit.validate()?; | |
| 136 | if selected.insert(edit.space, edit).is_some() || !guids.insert(edit.guid) { | |
| 132 | pub(crate) fn edit_pages( | |
| 133 | source: &[u8], | |
| 134 | edits: &[PageEdit], | |
| 135 | removals: &[ExGuid], | |
| 136 | ) -> Result<Vec<u8>, Error> { | |
| 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 | 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| { | |
| 143 | let document = Document::parse(index)?; | |
| 144 | let pages = document.pages()?; | |
| 145 | let section = &document.spaces[&document.root]; | |
| 146 | let view = &section.revisions[&section.contexts[&ExGuid::default()]]; | |
| 147 | let section_id = view.roots[&1]; | |
| 148 | let section_node = &view.nodes[&section_id]; | |
| 149 | if !matches!(section_node.kind, Kind::Section { .. }) | |
| 150 | || section_node.extra[0] | |
| 193 | let original_order = order.clone(); | |
| 194 | let original_levels = levels.clone(); | |
| 195 | let mut series_by_head = BTreeMap::new(); | |
| 196 | let mut copies = BTreeMap::new(); | |
| 197 | let mut copy_ids = BTreeSet::new(); | |
| 198 | for oid in &section_node.children { | |
| 199 | let series = &view.nodes[oid]; | |
| 200 | let Some(head) = series.spaces.first() else { | |
| 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 | 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(); | |
| 157 | let mut levels = BTreeMap::new(); | |
| 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); | |
| 212 | if let Some(retained) = series.spaces.iter().find(|sid| !removed.contains(sid)) { | |
| 213 | series_by_head.insert(*retained, *oid); | |
| 178 | 214 | } |
| 179 | let original_order = order.clone(); | |
| 180 | let original_levels = levels.clone(); | |
| 181 | let mut series_by_head = BTreeMap::new(); | |
| 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")); | |
| 215 | if let Some(field) = series.extra[0].iter().find(|field| field.id == 0x24003442) { | |
| 216 | let FieldValue::Objects(ids) = &field.value else { | |
| 217 | return Err(invalid("Page metadata copies must be object references")); | |
| 188 | 218 | }; |
| 189 | if levels.get(head) != Some(&1) | |
| 190 | || series.spaces[1..] | |
| 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 | )); | |
| 219 | if ids.len() != series.spaces.len() { | |
| 220 | return Err(invalid("Page metadata copies must match their series")); | |
| 197 | 221 | } |
| 198 | series_by_head.insert(*head, *oid); | |
| 199 | if let Some(field) = series.extra[0].iter().find(|field| field.id == 0x24003442) { | |
| 200 | let FieldValue::Objects(ids) = &field.value else { | |
| 201 | return Err(invalid("Page metadata copies must be object references")); | |
| 202 | }; | |
| 203 | if ids.len() != series.spaces.len() { | |
| 204 | return Err(invalid("Page metadata copies must match their series")); | |
| 222 | for id in ids { | |
| 223 | if !copy_ids.insert(*id) | |
| 224 | || !matches!( | |
| 225 | view.nodes.get(id).map(|n| &n.kind), | |
| 226 | Some(Kind::Metadata { .. }) | |
| 227 | ) | |
| 228 | { | |
| 229 | return Err(invalid("Each page needs its own ordinary metadata copy")); | |
| 205 | 230 | } |
| 206 | for id in ids { | |
| 207 | if !copy_ids.insert(*id) | |
| 208 | || !matches!( | |
| 209 | view.nodes.get(id).map(|n| &n.kind), | |
| 210 | Some(Kind::Metadata { .. }) | |
| 211 | ) | |
| 212 | { | |
| 213 | 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 | } | |
| 231 | let sid = page_guids | |
| 232 | .get(&metadata_guid(&view.nodes[id])?) | |
| 233 | .filter(|sid| series.spaces.contains(sid)) | |
| 234 | .ok_or_else(|| { | |
| 235 | invalid("Metadata copy must identify a page in its series") | |
| 236 | })?; | |
| 237 | if copies.insert(*sid, *id).is_some() { | |
| 238 | return Err(invalid("Each page needs its own ordinary metadata copy")); | |
| 224 | 239 | } |
| 225 | 240 | } |
| 226 | 241 | } |
| 227 | for edit in edits { | |
| 228 | let Some(at) = order.iter().position(|sid| *sid == edit.space) else { | |
| 229 | return Err(invalid("The selected page is no longer in the section")); | |
| 230 | }; | |
| 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 | } | |
| 242 | } | |
| 243 | for sid in &removed { | |
| 244 | if !levels.contains_key(sid) { | |
| 245 | return Err(invalid("The selected page is no longer in the section")); | |
| 244 | 246 | } |
| 245 | if order.first().is_some_and(|sid| levels[sid] != 1) { | |
| 246 | return Err(invalid( | |
| 247 | "The section's first page must have indentation level 1", | |
| 248 | )); | |
| 247 | } | |
| 248 | order.retain(|sid| !removed.contains(sid)); | |
| 249 | if !removed.is_empty() | |
| 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 { | |
| 251 | return Ok(BTreeMap::new()); | |
| 271 | } | |
| 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()])?; | |
| 254 | let mut groups: Vec<Vec<ExGuid>> = Vec::new(); | |
| 255 | for sid in order { | |
| 256 | if levels[&sid] == 1 { | |
| 257 | groups.push(Vec::new()); | |
| 258 | } | |
| 259 | groups.last_mut().unwrap().push(sid); | |
| 286 | groups.last_mut().unwrap().push(sid); | |
| 287 | } | |
| 288 | let mut changes = BTreeMap::new(); | |
| 289 | for sid in &removed { | |
| 290 | let page = index.resolve(*sid, index.spaces[sid].labels[&(ExGuid::default(), 1)])?; | |
| 291 | let manifest_id = page.roots[&1]; | |
| 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(); | |
| 262 | let mut replacements = BTreeMap::new(); | |
| 263 | let mut children = Vec::new(); | |
| 264 | for spaces in groups { | |
| 265 | let head = spaces[0]; | |
| 266 | let old_id = series_by_head.get(&head).copied(); | |
| 267 | let (id, mut series) = if let Some(id) = old_id { | |
| 268 | (id, PropertyObject::from_object(&raw.objects[&id])?) | |
| 269 | } else { | |
| 270 | let edit = selected | |
| 271 | .get(&head) | |
| 272 | .ok_or_else(|| invalid("A new series must start at an edited page"))?; | |
| 273 | let id = ExGuid { | |
| 274 | guid: edit.guid, | |
| 275 | n: 1, | |
| 276 | }; | |
| 277 | if raw.objects.contains_key(&id) { | |
| 278 | return Err(invalid("The new page-series identity already exists")); | |
| 279 | } | |
| 280 | ( | |
| 281 | id, | |
| 282 | PropertyObject { | |
| 283 | jcid: 0x60008, | |
| 284 | bytes: properties(&[(0x1c001c30, edit.guid.to_vec())])?, | |
| 285 | global_ids: Arc::new(BTreeMap::from([(0, edit.guid)])), | |
| 286 | }, | |
| 287 | ) | |
| 296 | manifest.bytes = properties(&[])?; | |
| 297 | let metadata_id = page.roots[&2]; | |
| 298 | let mut metadata = PropertyObject::from_object(&page.objects[&metadata_id])?; | |
| 299 | metadata.set(&[(0x88001de9, &[])])?; | |
| 300 | changes.insert( | |
| 301 | *sid, | |
| 302 | RevisionEdit::Update(BTreeMap::from([ | |
| 303 | (manifest_id, manifest), | |
| 304 | (metadata_id, metadata), | |
| 305 | ])), | |
| 306 | ); | |
| 307 | } | |
| 308 | let mut replacements = BTreeMap::new(); | |
| 309 | let mut children = Vec::new(); | |
| 310 | for spaces in groups { | |
| 311 | let head = spaces[0]; | |
| 312 | let old_id = series_by_head.get(&head).copied(); | |
| 313 | let (id, mut series) = if let Some(id) = old_id { | |
| 314 | (id, PropertyObject::from_object(&raw.objects[&id])?) | |
| 315 | } else { | |
| 316 | let edit = selected | |
| 317 | .get(&head) | |
| 318 | .ok_or_else(|| invalid("A new series must start at an edited page"))?; | |
| 319 | let id = ExGuid { | |
| 320 | guid: edit.guid, | |
| 321 | n: 1, | |
| 288 | 322 | }; |
| 289 | children.push(id); | |
| 290 | let membership_changed = old_id.is_none_or(|old| view.nodes[&old].spaces != spaces); | |
| 291 | if !membership_changed | |
| 292 | && !spaces.iter().any(|sid| { | |
| 293 | levels[sid] != original_levels[sid] | |
| 294 | || copies.get(sid).is_some_and(|id| { | |
| 295 | matches!(view.nodes[id].kind, Kind::Metadata { level, .. } | |
| 323 | if raw.objects.contains_key(&id) { | |
| 324 | return Err(invalid("The new page-series identity already exists")); | |
| 325 | } | |
| 326 | ( | |
| 327 | id, | |
| 328 | PropertyObject { | |
| 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 | 342 | if level.unwrap_or(1) != levels[sid]) |
| 297 | }) | |
| 298 | }) | |
| 299 | { | |
| 300 | continue; | |
| 343 | }) | |
| 344 | }) | |
| 345 | { | |
| 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(); | |
| 303 | for sid in &spaces { | |
| 304 | let page = | |
| 305 | index.resolve(*sid, index.spaces[sid].labels[&(ExGuid::default(), 1)])?; | |
| 306 | let page_id = page.roots[&2]; | |
| 307 | let original = PropertyObject::from_object(&page.objects[&page_id])?; | |
| 308 | if old_id.is_none() && *sid == head { | |
| 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); | |
| 357 | if levels[sid] != original_levels[sid] { | |
| 358 | let mut metadata = PropertyObject::from_object(&page.objects[&page_id])?; | |
| 359 | metadata.set(&[(0x14001dff, &levels[sid].to_le_bytes())])?; | |
| 360 | changes.insert( | |
| 361 | *sid, | |
| 362 | RevisionEdit::Update(BTreeMap::from([(page_id, metadata)])), | |
| 363 | ); | |
| 340 | 364 | } |
| 341 | if membership_changed || spaces.iter().any(|sid| !copies.contains_key(sid)) { | |
| 342 | set_references(&mut series, 0x2c001d63, &spaces)?; | |
| 343 | set_references(&mut series, 0x24003442, &metadata_ids)?; | |
| 344 | if replacements.insert(id, series).is_some() { | |
| 345 | return Err(invalid("Page-series and metadata identities overlap")); | |
| 365 | let copy_id = copies | |
| 366 | .get(sid) | |
| 367 | .copied() | |
| 368 | .unwrap_or_else(|| metadata_id(*sid)); | |
| 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 { | |
| 350 | let mut parent = PropertyObject::from_object(&raw.objects[&section_id])?; | |
| 351 | set_references(&mut parent, 0x24001c20, &children)?; | |
| 352 | replacements.insert(section_id, parent); | |
| 387 | if membership_changed || spaces.iter().any(|sid| !copies.contains_key(sid)) { | |
| 388 | set_references(&mut series, 0x2c001d63, &spaces)?; | |
| 389 | set_references(&mut series, 0x24003442, &metadata_ids)?; | |
| 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)); | |
| 355 | Ok(changes) | |
| 356 | }) | |
| 357 | } | |
| 394 | } | |
| 395 | if children != section_node.children { | |
| 396 | let mut parent = PropertyObject::from_object(&raw.objects[&section_id])?; | |
| 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 | } |
| 359 | 404 | |
| 360 | 405 | /// An empty top-level page with stable identities and creation time. |
| ... | ... | @@ -685,7 +730,7 @@ mod tests { |
| 685 | 730 | } |
| 686 | 731 | } |
| 687 | 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 | 734 | let store = Store::parse(&written).unwrap(); |
| 690 | 735 | let index = RevisionIndex::parse(&store).unwrap(); |
| 691 | 736 | index.validate_current().unwrap(); |
| ... | ... | @@ -761,7 +806,12 @@ mod tests { |
| 761 | 806 | }) |
| 762 | 807 | .unwrap(); |
| 763 | 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 | 860 | .map(|(i, (sid, _))| PageEdit::move_to(*sid, None, i as u32 + 1).unwrap()) |
| 811 | 861 | .collect(), |
| 812 | 862 | ] { |
| 813 | let written = PageEdit::apply(&source, &edits).unwrap(); | |
| 863 | let written = edit_pages(&source, &edits, &[]).unwrap(); | |
| 814 | 864 | let old_store = Store::parse(&source).unwrap(); |
| 815 | 865 | let new_store = Store::parse(&written).unwrap(); |
| 816 | 866 | let old = RevisionIndex::parse(&old_store).unwrap(); |
crates/onestore/tests/page_deletion.rs created+371| ... | ... | @@ -0,0 +1,371 @@ |
| 1 | use onestore::{ | |
| 2 | ExGuid, FileDataReference, ObjectData, PreparedEdit, PropertySets, RevisionIndex, Store, | |
| 3 | document::Document, | |
| 4 | }; | |
| 5 | use std::collections::BTreeSet; | |
| 6 | ||
| 7 | const SOURCE: &[u8] = | |
| 8 | include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); | |
| 9 | ||
| 10 | fn 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] | |
| 113 | fn 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] | |
| 148 | fn 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 | ||
| 178 | use page_schedule::{current, disk}; | |
| 179 | ||
| 180 | #[test] | |
| 181 | fn 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] | |
| 239 | fn 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"] | |
| 276 | mod page_schedule; | |
| 277 | ||
| 278 | #[test] | |
| 279 | fn 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] | |
| 298 | fn 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 @@ |
| 1 | use onestore::{ | |
| 2 | CommitState, ExGuid, Insertion, PageCreation, PageEdit, PreparedEdit, RevisionIndex, Store, | |
| 3 | document::{Document, FieldValue, Kind}, | |
| 4 | }; | |
| 5 | use std::{collections::BTreeMap, sync::LazyLock}; | |
| 6 | ||
| 7 | #[path = "current.rs"] | |
| 8 | pub(crate) mod current; | |
| 9 | #[path = "disk.rs"] | |
| 10 | pub(crate) mod disk; | |
| 11 | ||
| 12 | static SOURCE: LazyLock<Vec<u8>> = LazyLock::new(|| { | |
| 13 | onestore::create_section("pages.one", "Original 🦀 é 東京", "Author").unwrap() | |
| 14 | }); | |
| 15 | ||
| 16 | pub 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 | 1 | #![no_main] |
| 2 | 2 | use libfuzzer_sys::fuzz_target; |
| 3 | use onestore::{ | |
| 4 | CommitState, ExGuid, Insertion, PageCreation, PageEdit, PreparedEdit, RevisionIndex, Store, | |
| 5 | document::{Document, FieldValue, Kind}, | |
| 6 | }; | |
| 7 | use std::{collections::BTreeMap, sync::LazyLock}; | |
| 8 | 3 | |
| 9 | #[path = "../../crates/onestore/tests/support/current.rs"] | |
| 10 | mod current; | |
| 11 | #[path = "../../crates/onestore/tests/support/disk.rs"] | |
| 12 | mod disk; | |
| 4 | #[path = "../../crates/onestore/tests/support/page_schedule.rs"] | |
| 5 | mod page_schedule; | |
| 13 | 6 | |
| 14 | static SOURCE: LazyLock<Vec<u8>> = LazyLock::new(|| { | |
| 15 | onestore::create_section("pages.one", "Original 🦀 é 東京", "Author").unwrap() | |
| 16 | }); | |
| 17 | ||
| 18 | fuzz_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 | }); | |
| 7 | fuzz_target!(|input: &[u8]| page_schedule::run(input)); |
readme.md+5| ... | ... | @@ -1 +1,6 @@ |
| 1 | 1 | # Snowbound — Freeform note taking |
| 2 | ||
| 3 | Type and draw notes on any platform, while maintaining ownership of your data. | |
| 4 | ||
| 5 | Snowbound implements the file and sync protocol used in 2010 Microsoft OneNote, | |
| 6 | so notebooks are fully compatible. |