| 1 | //! Page versions against OneNote 2010's own (`corpus/page-versions/native`): listing them, |
| 2 | //! reading them, and Restore Version and Delete Version storing what OneNote stores. |
| 3 | |
| 4 | use onestore::{ |
| 5 | Arena, ExGuid, ObjectData, PageVersion, PropertySets, RevisionIndex, Section, Store, |
| 6 | document::Document, |
| 7 | op::{Edit, Op, PageOp, SectionOp}, |
| 8 | page::{Page, PageObject}, |
| 9 | }; |
| 10 | use std::collections::BTreeSet; |
| 11 | |
| 12 | const AT: u64 = 134_040_000_000_000_000; |
| 13 | const NATIVE: &str = "corpus/page-versions/native"; |
| 14 | /// The version history's context. |
| 15 | const HISTORY: &str = "{7111497F-1B6B-4209-9491-C98B04CF4C5A},1"; |
| 16 | |
| 17 | fn read(path: &str) -> Vec<u8> { |
| 18 | std::fs::read(format!("{}/../../{path}", env!("CARGO_MANIFEST_DIR"))).unwrap() |
| 19 | } |
| 20 | |
| 21 | fn step(n: u32) -> Vec<u8> { |
| 22 | read(&format!("{NATIVE}/step-{n:02}/notebook/History.one")) |
| 23 | } |
| 24 | |
| 25 | fn id(text: &str) -> ExGuid { |
| 26 | text.parse().unwrap() |
| 27 | } |
| 28 | |
| 29 | /// The page every step holds, and the version OneNote made when a second author edited it. |
| 30 | fn page() -> ExGuid { |
| 31 | id("{55479BA2-FB3A-45EB-A43D-EDB93DA0593D},1") |
| 32 | } |
| 33 | |
| 34 | fn texts(page: &Page) -> Vec<String> { |
| 35 | page.objects |
| 36 | .iter() |
| 37 | .flat_map(|object| match object { |
| 38 | PageObject::Outline(outline) => outline.paragraphs.clone(), |
| 39 | _ => Vec::new(), |
| 40 | }) |
| 41 | .filter_map(|paragraph| paragraph.text().map(|text| text.text.text().to_owned())) |
| 42 | .collect() |
| 43 | } |
| 44 | |
| 45 | fn versions(image: &[u8]) -> Vec<PageVersion> { |
| 46 | let arena = Arena::default(); |
| 47 | let mut section = Section::open(&arena, image.to_vec()).unwrap(); |
| 48 | section |
| 49 | .versions() |
| 50 | .unwrap() |
| 51 | .into_iter() |
| 52 | .find(|(listed, _)| *listed == page()) |
| 53 | .map_or(Vec::new(), |(_, versions)| versions) |
| 54 | } |
| 55 | |
| 56 | /// What a revision of `space` changed: its declared objects' types, sorted, with whether |
| 57 | /// each was new. |
| 58 | fn changed(index: &RevisionIndex<'_>, space: ExGuid, rid: ExGuid) -> Vec<(u32, bool)> { |
| 59 | let revision = index.resolve(space, rid).unwrap(); |
| 60 | let base = index.spaces[&space].revisions[&rid] |
| 61 | .dependency |
| 62 | .map(|dependency| index.resolve(space, dependency).unwrap()); |
| 63 | let mut changed: Vec<(u32, bool)> = revision |
| 64 | .objects |
| 65 | .iter() |
| 66 | .filter_map(|(id, object)| { |
| 67 | let old = base.as_ref().and_then(|base| base.objects.get(id)); |
| 68 | let same = old.is_some_and(|old| { |
| 69 | old.reference_count == object.reference_count && old.data == object.data |
| 70 | }); |
| 71 | (!same).then_some((object.jcid, old.is_none())) |
| 72 | }) |
| 73 | .collect(); |
| 74 | changed.sort(); |
| 75 | changed |
| 76 | } |
| 77 | |
| 78 | /// Property identities of an object's root set. |
| 79 | fn properties(object: &onestore::Object<'_>) -> Vec<u32> { |
| 80 | let ObjectData::Properties(bytes) = object.data else { |
| 81 | panic!() |
| 82 | }; |
| 83 | PropertySets::parse(bytes).unwrap().sets[0] |
| 84 | .iter() |
| 85 | .map(|property| property.id) |
| 86 | .collect() |
| 87 | } |
| 88 | |
| 89 | /// How an edit left the page's revisions, from `before` to `after`: the page revision's |
| 90 | /// dependency (named by what it was before), the history's, which revisions became versions, |
| 91 | /// the declared object types of the page and history revisions, the history's children |
| 92 | /// count and the proxy's properties, and whether the page metadata and the section's copy |
| 93 | /// say the page has versions. |
| 94 | #[derive(Debug, PartialEq)] |
| 95 | struct Shape { |
| 96 | page_depends_on: &'static str, |
| 97 | history_depends_on_previous: bool, |
| 98 | labelled: Vec<&'static str>, |
| 99 | page_changed: Vec<(u32, bool)>, |
| 100 | history_changed: Vec<(u32, bool)>, |
| 101 | listed: usize, |
| 102 | proxy: Vec<u32>, |
| 103 | flagged: [bool; 2], |
| 104 | } |
| 105 | |
| 106 | fn shape(before: &[u8], after: &[u8]) -> Shape { |
| 107 | let (old_store, new_store) = (Store::parse(before).unwrap(), Store::parse(after).unwrap()); |
| 108 | let (old, new) = ( |
| 109 | RevisionIndex::parse(&old_store).unwrap(), |
| 110 | RevisionIndex::parse(&new_store).unwrap(), |
| 111 | ); |
| 112 | new.validate_current().unwrap(); |
| 113 | Document::parse(&new).unwrap(); |
| 114 | let space = page(); |
| 115 | let history = id(HISTORY); |
| 116 | let (old_labels, new_labels) = (&old.spaces[&space].labels, &new.spaces[&space].labels); |
| 117 | let old_page = old_labels[&(ExGuid::default(), 1)]; |
| 118 | let new_page = new_labels[&(ExGuid::default(), 1)]; |
| 119 | let name = |rid: ExGuid| -> &'static str { |
| 120 | if rid == old_page { |
| 121 | "the page" |
| 122 | } else if old_labels.get(&(history, 1)) == Some(&rid) { |
| 123 | "the history" |
| 124 | } else if old_labels.iter().any(|((context, role), labelled)| { |
| 125 | *role == 1 && *context != ExGuid::default() && *labelled == rid |
| 126 | }) { |
| 127 | "a version" |
| 128 | } else { |
| 129 | "another revision" |
| 130 | } |
| 131 | }; |
| 132 | let page_depends_on = if new_page == old_page { |
| 133 | "unchanged" |
| 134 | } else { |
| 135 | new.spaces[&space].revisions[&new_page] |
| 136 | .dependency |
| 137 | .map_or("none", name) |
| 138 | }; |
| 139 | let (old_history, new_history) = (old_labels[&(history, 1)], new_labels[&(history, 1)]); |
| 140 | let labelled = new_labels |
| 141 | .iter() |
| 142 | .filter(|((context, role), _)| { |
| 143 | *role == 1 |
| 144 | && *context != ExGuid::default() |
| 145 | && *context != history |
| 146 | && !old_labels.contains_key(&(*context, 1)) |
| 147 | }) |
| 148 | .map(|(_, rid)| name(*rid)) |
| 149 | .collect(); |
| 150 | let history_revision = new.resolve(space, new_history).unwrap(); |
| 151 | let root = &history_revision.objects[&history_revision.roots[&1]]; |
| 152 | let listed = root.references().unwrap().objects; |
| 153 | let proxy = listed.first().map_or(Vec::new(), |proxy| { |
| 154 | properties(&history_revision.objects[proxy]) |
| 155 | }); |
| 156 | let metadata = |index: &RevisionIndex<'_>| -> [bool; 2] { |
| 157 | let revision = index.resolve_active(space).unwrap(); |
| 158 | let own = properties(&revision.objects[&revision.roots[&2]]).contains(&0x88003462); |
| 159 | // The section's copy, named by the page's space (MS-ONE 2.2.81). |
| 160 | let section = index.resolve_active(index.root).unwrap(); |
| 161 | let copy = properties(&section.objects[&id("{77EF5B93-CD3A-0705-1329-3A15E7846CD5},1")]) |
| 162 | .contains(&0x88003462); |
| 163 | [own, copy] |
| 164 | }; |
| 165 | Shape { |
| 166 | page_depends_on, |
| 167 | history_depends_on_previous: new_history != old_history |
| 168 | && new.spaces[&space].revisions[&new_history].dependency == Some(old_history), |
| 169 | labelled, |
| 170 | page_changed: if new_page == old_page { |
| 171 | Vec::new() |
| 172 | } else { |
| 173 | changed(&new, space, new_page) |
| 174 | }, |
| 175 | history_changed: if new_history == old_history { |
| 176 | Vec::new() |
| 177 | } else { |
| 178 | changed(&new, space, new_history) |
| 179 | }, |
| 180 | listed: listed.len(), |
| 181 | proxy, |
| 182 | flagged: metadata(&new), |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | fn apply(image: &[u8], author: &str, ops: Vec<Op>) -> Vec<u8> { |
| 187 | let arena = Arena::default(); |
| 188 | let mut section = Section::open(&arena, image.to_vec()).unwrap(); |
| 189 | section.apply(author, &Edit { at: AT, ops }).unwrap(); |
| 190 | section.seal().unwrap().unwrap(); |
| 191 | section.image() |
| 192 | } |
| 193 | |
| 194 | #[test] |
| 195 | fn native_versions_are_listed_newest_first_with_their_authors() { |
| 196 | assert_eq!(versions(&step(1)), []); |
| 197 | let virtual_version = PageVersion { |
| 198 | context: id("{2F6B7C6D-DF95-0F56-085E-74357323A760},1"), |
| 199 | modified: Some(0x01dd4ee2641a3f00), |
| 200 | author: Some("virtual".into()), |
| 201 | }; |
| 202 | assert_eq!(versions(&step(2)), std::slice::from_ref(&virtual_version)); |
| 203 | // A Restore Version made the page as it stood the newest version. |
| 204 | let restored = versions(&step(4)); |
| 205 | assert_eq!( |
| 206 | restored, |
| 207 | [ |
| 208 | PageVersion { |
| 209 | context: id("{783B5BD2-65DB-082D-3AAC-47135BA38EA9},1"), |
| 210 | modified: Some(0x01dd4ee27e541d00), |
| 211 | author: Some("Other Person".into()), |
| 212 | }, |
| 213 | virtual_version.clone(), |
| 214 | ] |
| 215 | ); |
| 216 | assert_eq!(versions(&step(5)), [virtual_version]); |
| 217 | assert_eq!(versions(&step(6)), []); |
| 218 | // Optimizing keeps a version and the history as checkpoints. |
| 219 | assert_eq!(versions(&step(10)).len(), 1); |
| 220 | } |
| 221 | |
| 222 | #[test] |
| 223 | fn a_version_reads_as_the_page_it_was() { |
| 224 | let arena = Arena::default(); |
| 225 | let section = Section::open(&arena, step(1)).unwrap(); |
| 226 | let before = section.page(page()).unwrap(); |
| 227 | let arena = Arena::default(); |
| 228 | let mut section = Section::open(&arena, step(2)).unwrap(); |
| 229 | let context = section.versions().unwrap()[0].1[0].context; |
| 230 | let version = section.version(page(), context).unwrap(); |
| 231 | assert_eq!(version, before); |
| 232 | assert_eq!(texts(&version), ["First state. Typed by virtual."]); |
| 233 | assert_eq!( |
| 234 | texts(&section.page(page()).unwrap()), |
| 235 | ["First state. Typed by virtual.", "Second author line."] |
| 236 | ); |
| 237 | assert!(section.version(page(), id(HISTORY)).is_err()); |
| 238 | } |
| 239 | |
| 240 | /// Restore Version stores what OneNote 2010's does (`step-03` to `step-04`): the page's next |
| 241 | /// revision builds on the version's, the page as it stood becomes the newest version under |
| 242 | /// the context its revision derives, the history lists it first, and the revision metadata |
| 243 | /// names who restored it. `ONESTORE_VERSIONS_EXPORT` names a directory for the notebooks, |
| 244 | /// for a cold read. |
| 245 | #[test] |
| 246 | fn restoring_a_version_stores_what_onenote_stores() { |
| 247 | let before = step(3); |
| 248 | let restored_version = versions(&before)[0].context; |
| 249 | let arena = Arena::default(); |
| 250 | let section = Section::open(&arena, before.clone()).unwrap(); |
| 251 | let standing = section.page(page()).unwrap(); |
| 252 | let version = section.version(page(), restored_version).unwrap(); |
| 253 | let ours = apply( |
| 254 | &before, |
| 255 | "Other Person", |
| 256 | vec![Op::Section(SectionOp::RestoreVersion { |
| 257 | page: page(), |
| 258 | version: restored_version, |
| 259 | guid: [7; 16], |
| 260 | })], |
| 261 | ); |
| 262 | let expected = shape(&before, &step(4)); |
| 263 | assert_eq!(expected.page_depends_on, "a version"); |
| 264 | assert_eq!(shape(&before, &ours), expected); |
| 265 | |
| 266 | let arena = Arena::default(); |
| 267 | let mut reopened = Section::open(&arena, ours.clone()).unwrap(); |
| 268 | assert_eq!(reopened.page(page()).unwrap().objects, version.objects); |
| 269 | let listed = reopened.versions().unwrap().remove(0).1; |
| 270 | assert_eq!(listed.len(), 2); |
| 271 | assert_eq!(listed[1].context, restored_version); |
| 272 | // As OneNote names it (`step-04`): the page's revision, its bits flipped by a salt. |
| 273 | let store = Store::parse(&before).unwrap(); |
| 274 | let standing_rid = RevisionIndex::parse(&store) |
| 275 | .unwrap() |
| 276 | .active(page()) |
| 277 | .unwrap(); |
| 278 | let salt = |a: ExGuid, b: ExGuid| -> Vec<u8> { |
| 279 | a.guid.iter().zip(b.guid).map(|(a, b)| a ^ b).collect() |
| 280 | }; |
| 281 | assert_eq!( |
| 282 | salt(listed[0].context, standing_rid), |
| 283 | salt( |
| 284 | id("{783B5BD2-65DB-082D-3AAC-47135BA38EA9},1"), |
| 285 | id("{E569A5AE-EA67-4CB8-A9AC-55880C342743},1") |
| 286 | ) |
| 287 | ); |
| 288 | assert_eq!(listed[0].author.as_deref(), Some("Other Person")); |
| 289 | assert_eq!( |
| 290 | reopened.version(page(), listed[0].context).unwrap(), |
| 291 | standing |
| 292 | ); |
| 293 | |
| 294 | if let Some(output) = std::env::var_os("ONESTORE_VERSIONS_EXPORT") { |
| 295 | export(&output, "restore", &ours); |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | /// Delete Version stores what OneNote 2010's does: the history stops listing the version |
| 300 | /// (`step-04` to `step-05`), and once none is left the page's metadata and the section's copy |
| 301 | /// stop saying it has versions (`step-05` to `step-06`). |
| 302 | #[test] |
| 303 | fn deleting_versions_stores_what_onenote_stores() { |
| 304 | let before = step(4); |
| 305 | let newest = versions(&before)[0].context; |
| 306 | let delete = |image: &[u8], versions: Vec<ExGuid>| { |
| 307 | apply( |
| 308 | image, |
| 309 | "Other Person", |
| 310 | vec![Op::Section(SectionOp::DeleteVersions { |
| 311 | page: page(), |
| 312 | versions, |
| 313 | })], |
| 314 | ) |
| 315 | }; |
| 316 | let ours = delete(&before, vec![newest]); |
| 317 | assert_eq!(shape(&before, &ours), shape(&before, &step(5))); |
| 318 | let emptied = delete(&step(5), vec![versions(&step(5))[0].context]); |
| 319 | let expected = shape(&step(5), &step(6)); |
| 320 | assert_eq!(expected.flagged, [false, false]); |
| 321 | assert_eq!(shape(&step(5), &emptied), expected); |
| 322 | // Every version at once, as Delete All Versions in Section asks. |
| 323 | let all = versions(&before) |
| 324 | .into_iter() |
| 325 | .map(|version| version.context) |
| 326 | .collect(); |
| 327 | assert_eq!(versions(&delete(&before, all)), []); |
| 328 | // A version already gone refuses the edit. |
| 329 | let arena = Arena::default(); |
| 330 | let mut section = Section::open(&arena, step(5)).unwrap(); |
| 331 | let refused = section.apply( |
| 332 | "Other Person", |
| 333 | &Edit { |
| 334 | at: AT, |
| 335 | ops: vec![Op::Section(SectionOp::DeleteVersions { |
| 336 | page: page(), |
| 337 | versions: vec![newest], |
| 338 | })], |
| 339 | }, |
| 340 | ); |
| 341 | assert_eq!( |
| 342 | refused, |
| 343 | Err(onestore::op::OpError::TargetUnavailable(newest)) |
| 344 | ); |
| 345 | |
| 346 | if let Some(output) = std::env::var_os("ONESTORE_VERSIONS_EXPORT") { |
| 347 | export(&output, "delete", &ours); |
| 348 | export(&output, "delete-all", &emptied); |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | /// Edits not yet sealed become a version of their own when a restore follows them in the |
| 353 | /// same batch, and restores stack: every state the page passed through stays listed. |
| 354 | #[test] |
| 355 | fn unsaved_edits_and_stacked_restores_become_versions() { |
| 356 | let before = step(2); |
| 357 | let arena = Arena::default(); |
| 358 | let mut section = Section::open(&arena, before.clone()).unwrap(); |
| 359 | let first = section.versions().unwrap()[0].1[0].context; |
| 360 | let text = section |
| 361 | .page(page()) |
| 362 | .unwrap() |
| 363 | .objects |
| 364 | .iter() |
| 365 | .find_map(|object| match object { |
| 366 | PageObject::Outline(outline) => outline.paragraphs.last()?.text().map(|text| text.id), |
| 367 | _ => None, |
| 368 | }); |
| 369 | let edit = |ops| Edit { at: AT, ops }; |
| 370 | section |
| 371 | .apply( |
| 372 | "Clover Snow", |
| 373 | &edit(vec![Op::Page { |
| 374 | space: page(), |
| 375 | op: PageOp::Text { |
| 376 | text: text.unwrap(), |
| 377 | range: 0..6, |
| 378 | with: "Unsaved".into(), |
| 379 | }, |
| 380 | }]), |
| 381 | ) |
| 382 | .unwrap(); |
| 383 | let edited = section.page(page()).unwrap(); |
| 384 | let restore = |version, guid| { |
| 385 | edit(vec![Op::Section(SectionOp::RestoreVersion { |
| 386 | page: page(), |
| 387 | version, |
| 388 | guid, |
| 389 | })]) |
| 390 | }; |
| 391 | section |
| 392 | .apply("Clover Snow", &restore(first, [1; 16])) |
| 393 | .unwrap(); |
| 394 | let listed = section.versions().unwrap()[0].1.clone(); |
| 395 | assert_eq!(listed.len(), 2); |
| 396 | assert_eq!(section.version(page(), listed[0].context).unwrap(), edited); |
| 397 | // The unsaved state is still being saved, so it cannot be restored yet; the stored one can. |
| 398 | assert!( |
| 399 | section |
| 400 | .apply("Clover Snow", &restore(listed[0].context, [2; 16])) |
| 401 | .is_err() |
| 402 | ); |
| 403 | section |
| 404 | .apply("Clover Snow", &restore(first, [3; 16])) |
| 405 | .unwrap(); |
| 406 | assert_eq!(section.versions().unwrap()[0].1.len(), 3); |
| 407 | section.seal().unwrap().unwrap(); |
| 408 | let image = section.image(); |
| 409 | |
| 410 | let arena = Arena::default(); |
| 411 | let mut reopened = Section::open(&arena, image.clone()).unwrap(); |
| 412 | let store = Store::parse(&image).unwrap(); |
| 413 | let index = RevisionIndex::parse(&store).unwrap(); |
| 414 | index.validate_current().unwrap(); |
| 415 | Document::parse(&index).unwrap(); |
| 416 | let listed = reopened.versions().unwrap().remove(0).1; |
| 417 | assert_eq!(listed.len(), 3); |
| 418 | let pages: Vec<Page> = listed |
| 419 | .iter() |
| 420 | .map(|version| reopened.version(page(), version.context).unwrap()) |
| 421 | .collect(); |
| 422 | let version = reopened.version(page(), first).unwrap(); |
| 423 | assert_eq!(pages[1], edited); |
| 424 | assert_eq!(pages[0].objects, version.objects); |
| 425 | assert_eq!(reopened.page(page()).unwrap().objects, version.objects); |
| 426 | // The restored page edits on as any other. |
| 427 | let text = texts(&reopened.page(page()).unwrap()); |
| 428 | assert_eq!(text, ["First state. Typed by virtual."]); |
| 429 | } |
| 430 | |
| 431 | /// An edit that fails after a restore leaves the section as it was before the edit. |
| 432 | #[test] |
| 433 | fn a_failed_edit_undoes_its_restore() { |
| 434 | let before = step(2); |
| 435 | let arena = Arena::default(); |
| 436 | let mut section = Section::open(&arena, before.clone()).unwrap(); |
| 437 | let first = section.versions().unwrap()[0].1[0].context; |
| 438 | let page_before = section.page(page()).unwrap(); |
| 439 | let refused = section.apply( |
| 440 | "Clover Snow", |
| 441 | &Edit { |
| 442 | at: AT, |
| 443 | ops: vec![ |
| 444 | Op::Section(SectionOp::RestoreVersion { |
| 445 | page: page(), |
| 446 | version: first, |
| 447 | guid: [1; 16], |
| 448 | }), |
| 449 | Op::Section(SectionOp::DeleteVersions { |
| 450 | page: page(), |
| 451 | versions: vec![id(HISTORY)], |
| 452 | }), |
| 453 | ], |
| 454 | }, |
| 455 | ); |
| 456 | assert!(refused.is_err()); |
| 457 | assert_eq!(section.page(page()).unwrap(), page_before); |
| 458 | assert_eq!(section.versions().unwrap()[0].1.len(), 1); |
| 459 | assert!(section.seal().unwrap().is_none()); |
| 460 | assert!(section.image() == before); |
| 461 | } |
| 462 | |
| 463 | #[test] |
| 464 | fn private_notebook_versions_read() { |
| 465 | let path = format!( |
| 466 | "{}/../../corpus/private/current/Art/Album.one", |
| 467 | env!("CARGO_MANIFEST_DIR") |
| 468 | ); |
| 469 | let Ok(image) = std::fs::read(path) else { |
| 470 | return; |
| 471 | }; |
| 472 | let arena = Arena::default(); |
| 473 | let mut section = Section::open(&arena, image).unwrap(); |
| 474 | let listed = section.versions().unwrap(); |
| 475 | assert!( |
| 476 | listed |
| 477 | .iter() |
| 478 | .map(|(_, versions)| versions.len()) |
| 479 | .sum::<usize>() |
| 480 | >= 10 |
| 481 | ); |
| 482 | let contexts: BTreeSet<ExGuid> = listed |
| 483 | .iter() |
| 484 | .flat_map(|(_, versions)| versions.iter().map(|version| version.context)) |
| 485 | .collect(); |
| 486 | assert_eq!( |
| 487 | contexts.len(), |
| 488 | listed.iter().map(|(_, v)| v.len()).sum::<usize>() |
| 489 | ); |
| 490 | for (page, versions) in &listed { |
| 491 | for version in versions { |
| 492 | section.version(*page, version.context).unwrap(); |
| 493 | } |
| 494 | } |
| 495 | } |
| 496 | |
| 497 | fn export(output: &std::ffi::OsStr, name: &str, image: &[u8]) { |
| 498 | let directory = std::path::Path::new(output).join(name); |
| 499 | std::fs::create_dir_all(&directory).unwrap(); |
| 500 | std::fs::write(directory.join("History.one"), image).unwrap(); |
| 501 | std::fs::write( |
| 502 | directory.join("Open Notebook.onetoc2"), |
| 503 | read(&format!("{NATIVE}/step-01/notebook/Open Notebook.onetoc2")), |
| 504 | ) |
| 505 | .unwrap(); |
| 506 | } |