| 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,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 |
| 92 | their identities; `reposition(PagePosition, level)` revises their placement while | 93 | their identities; `reposition(PagePosition, level)` revises their placement while |
| 93 | preserving those identities. [Native page-edit fixtures](../../corpus/page-lifecycle/page-edits/README.md) | 94 | preserving those identities. [Native page-edit fixtures](../../corpus/page-lifecycle/page-edits/README.md) |
| 94 | cover individual tabs, selected and collapsed groups, nesting and promotion. | 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 | Insertions update child references, reference counts, modification times and automatic | 103 | Insertions update child references, reference counts, modification times and automatic |
| 96 | titles atomically. Paragraphs can be nested or inserted into table cells; outline | 104 | titles atomically. Paragraphs can be nested or inserted into table cells; outline |
| 97 | coordinates use points. `Insertion::with_formatting` includes nonoverlapping UTF-16 | 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,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 | } |
| 194 | 207 |
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 | } | ||
| 130 | 131 | ||
| 131 | pub(crate) fn apply(source: &[u8], edits: &[Self]) -> Result<Vec<u8>, Error> { | 132 | pub(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 = selected | 305 | ])), |
| 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_changed | 325 | } |
| 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 | } |
| 359 | 404 | ||
| 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 @@ | ||
| 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,239 +1,7 @@ |
| 1 | #![no_main] | 1 | #![no_main] |
| 2 | use libfuzzer_sys::fuzz_target; | 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"] | 4 | #[path = "../../crates/onestore/tests/support/page_schedule.rs"] |
| 10 | mod current; | 5 | mod page_schedule; |
| 11 | #[path = "../../crates/onestore/tests/support/disk.rs"] | ||
| 12 | mod disk; | ||
| 13 | 6 | ||
| 14 | static SOURCE: LazyLock<Vec<u8>> = LazyLock::new(|| { | 7 | fuzz_target!(|input: &[u8]| page_schedule::run(input)); |
| 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 | }); |
readme.md+5| ... | @@ -1 +1,6 @@ | ... | @@ -1 +1,6 @@ |
| 1 | # Snowbound — Freeform note taking | 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. |