1#[path = "support/metadata.rs"]
2mod metadata;
3#[path = "support/ops.rs"]
4mod ops;
5
6use onestore::{
7 ExGuid, ObjectData, PropertySets, RevisionIndex, Store, TocEdit, Value,
8 document::{Document, Kind},
9 op::{Edit, Op, PageOp},
10};
11use std::fs;
12
13/// The text object holding `text` in `source` and its page space.
14fn text_object(source: &[u8], text: &str) -> (ExGuid, ExGuid) {
15 let store = Store::parse(source).unwrap();
16 let index = RevisionIndex::parse(&store).unwrap();
17 let document = Document::parse(&index).unwrap();
18 let mut found = document.spaces.iter().flat_map(|(space, view)| {
19 view.active()
20 .unwrap()
21 .nodes
22 .iter()
23 .filter(|(_, node)| matches!(&node.kind, Kind::RichText { text: stored, .. } if stored == text))
24 .map(|(id, _)| (*space, *id))
25 .collect::<Vec<_>>()
26 });
27 let target = found.next().unwrap();
28 assert!(found.next().is_none());
29 target
30}
31
32/// `source`, a table of contents, with the notebook recoloured.
33fn recolored(source: &[u8], color: u32) -> Vec<u8> {
34 let mut image = source.to_vec();
35 let edit = TocEdit::Color(color);
36 if let Some(transaction) = onestore::edit_table_of_contents(source, &[edit]).unwrap() {
37 transaction.apply(&mut image).unwrap();
38 }
39 image
40}
41
42#[test]
43fn text_edit_appends_a_revision_and_preserves_every_prior_object() {
44 let source =
45 fs::read("../../corpus/native/20260905-05/snapshots/02-text/notebook/synthetic.one")
46 .unwrap();
47 let store = Store::parse(&source).unwrap();
48 let index = RevisionIndex::parse(&store).unwrap();
49 let (osid, oid) = text_object(&source, "Fictitious plain text.");
50 let whole = 0.."Fictitious plain text.".encode_utf16().count() as u32;
51 let typed = |with: &str| {
52 let op = PageOp::Text {
53 text: oid,
54 range: whole.clone(),
55 with: with.into(),
56 };
57 ops::page_edited(&source, osid, vec![op]).unwrap()
58 };
59 assert_eq!(typed("Fictitious plain text."), source);
60 for length in 0..=40 {
61 let value = "x".repeat(length);
62 let written = typed(&value);
63 assert_preserved_committed_bytes(&source, &written);
64 let parsed = Store::parse(&written).unwrap();
65 assert!(parsed.checksum_mismatches.is_empty());
66 assert_eq!(
67 parsed.header.transaction_count,
68 store.header.transaction_count + 1
69 );
70 assert_eq!(parsed.header.generation, store.header.generation + 1);
71 assert_ne!(parsed.header.deny_read_id, store.header.deny_read_id);
72 let after = RevisionIndex::parse(&parsed).unwrap();
73 after.validate_current().unwrap();
74 for (space_id, space) in &index.spaces {
75 for rid in space.revisions.keys() {
76 let before = index.resolve(*space_id, *rid).unwrap();
77 let unchanged = after.resolve(*space_id, *rid).unwrap();
78 assert_eq!(before.roots, unchanged.roots);
79 assert_eq!(before.objects.len(), unchanged.objects.len());
80 for (id, object) in before.objects {
81 let same = &unchanged.objects[&id];
82 assert_eq!(object.jcid, same.jcid);
83 assert_eq!(object.reference_count, same.reference_count);
84 assert!(
85 same.global_ids
86 .iter()
87 .all(|(entry, guid)| object.global_ids.get(entry) == Some(guid))
88 );
89 assert_eq!(object.data, same.data);
90 }
91 }
92 }
93 let document = Document::parse(&after).unwrap();
94 let Kind::RichText { text, .. } = &document.active(osid).unwrap().nodes[&oid].kind else {
95 panic!()
96 };
97 assert_eq!(*text, value);
98 }
99}
100
101#[test]
102fn toc_color_edit_uses_its_native_revision_encoding_and_crc() {
103 let source = fs::read(
104 "../../corpus/native/20260905-05/snapshots/02-text/notebook/Open Notebook.onetoc2",
105 )
106 .unwrap();
107 let store = Store::parse(&source).unwrap();
108 let index = RevisionIndex::parse(&store).unwrap();
109 let rid = index.spaces[&index.root].labels[&(ExGuid::default(), 1)];
110 let revision = index.resolve(index.root, rid).unwrap();
111 let written = recolored(&source, 0x996633);
112 let after = Store::parse(&written).unwrap();
113 assert!(after.checksum_mismatches.is_empty());
114 assert_preserved_committed_bytes(&source, &written);
115 let current = RevisionIndex::parse(&after).unwrap();
116 current.validate_current().unwrap();
117 let active = current
118 .resolve(
119 index.root,
120 current.spaces[&index.root].labels[&(ExGuid::default(), 1)],
121 )
122 .unwrap();
123 let recoloured: Vec<_> = active
124 .objects
125 .iter()
126 .filter(|(id, object)| {
127 revision
128 .objects
129 .get(id)
130 .is_none_or(|old| old.data != object.data)
131 })
132 .map(|(_, object)| object)
133 .collect();
134 let [entry] = recoloured[..] else {
135 panic!("{} objects changed", recoloured.len())
136 };
137 let ObjectData::Properties(blob) = entry.data else {
138 panic!()
139 };
140 assert!(
141 PropertySets::parse(blob).unwrap().sets[0]
142 .iter()
143 .any(|p| p.id == 0x14001cbe && p.value == Value::Bytes(&[0x33, 0x66, 0x99, 0]))
144 );
145}
146
147#[test]
148fn fresh_section_has_unique_identities_and_exact_utf16_text() {
149 let text = "Created in Rust: café 東京 🦀";
150 let first = onestore::create_section("synthetic.one", text, "Fixture Author").unwrap();
151 let second = onestore::create_section("synthetic.one", text, "Fixture Author").unwrap();
152 let a = Store::parse(&first).unwrap();
153 let b = Store::parse(&second).unwrap();
154 assert_ne!(a.header.file_id, b.header.file_id);
155 assert!(a.checksum_mismatches.is_empty());
156 let index = RevisionIndex::parse(&a).unwrap();
157 index.validate_current().unwrap();
158 assert_eq!(index.spaces.len(), 2);
159 let expected: Vec<_> = text
160 .encode_utf16()
161 .chain([0])
162 .flat_map(u16::to_le_bytes)
163 .collect();
164 let mut found = 0;
165 for (osid, space) in &index.spaces {
166 let revision = index
167 .resolve(*osid, space.labels[&(ExGuid::default(), 1)])
168 .unwrap();
169 for oid in revision.reachable().unwrap() {
170 let object = &revision.objects[&oid];
171 if object.jcid == 0x6000e {
172 let ObjectData::Properties(data) = object.data else {
173 panic!()
174 };
175 assert!(
176 PropertySets::parse(data).unwrap().sets[0]
177 .iter()
178 .any(|p| p.id == 0x1c001c22 && p.value == Value::Bytes(&expected))
179 );
180 found += 1;
181 }
182 }
183 }
184 assert_eq!(found, 1);
185 for text in ["line\nfeed", "embedded\0nul"] {
186 assert!(onestore::create_section("a.one", text, "").is_err());
187 }
188 for name in ["a.onetoc2", "../a.one", "a\\b.one", "a\0.one"] {
189 assert!(onestore::create_section(name, "text", "").is_err());
190 }
191}
192
193#[test]
194fn malformed_creation_sequences_are_rejected_before_object_resolution() {
195 let source = onestore::create_section("a.one", "text", "").unwrap();
196 let store = Store::parse(&source).unwrap();
197 let node = store
198 .lists
199 .values()
200 .flat_map(|list| &list.nodes)
201 .find(|node| node.id == 0x84)
202 .unwrap();
203 let at = node.offset;
204 let mut damaged = source.clone();
205 let header = u32::from_le_bytes(damaged[at..at + 4].try_into().unwrap());
206 damaged[at..at + 4].copy_from_slice(&((header & !0x3ff) | 0x5a).to_le_bytes());
207 let store = Store::parse(&damaged).unwrap();
208 assert_eq!(
209 RevisionIndex::parse(&store).unwrap_err().message,
210 "Object group lacks its dependency overrides"
211 );
212 let store = Store::parse(&source).unwrap();
213 let root = store
214 .lists
215 .values()
216 .find(|list| list.nodes.iter().any(|node| node.id == 4))
217 .unwrap();
218 let selector = root.nodes.iter().find(|node| node.id == 4).unwrap();
219 let start = root.nodes[0].offset;
220 let end = selector.offset + 24;
221 let mut damaged = source.clone();
222 damaged[start..end].rotate_right(24);
223 let store = Store::parse(&damaged).unwrap();
224 assert_eq!(
225 RevisionIndex::parse(&store).unwrap_err().message,
226 "Root object space is referenced before its declaration"
227 );
228}
229
230#[test]
231fn created_toc_preserves_section_order_names_and_identities() {
232 let entries = [("second.one", [2; 16]), ("first.one", [1; 16])];
233 let bytes = onestore::create_table_of_contents("Open Notebook.onetoc2", &entries).unwrap();
234 let store = Store::parse(&bytes).unwrap();
235 assert!(store.checksum_mismatches.is_empty());
236 let index = RevisionIndex::parse(&store).unwrap();
237 index.validate_current().unwrap();
238 let revision = index
239 .resolve(
240 index.root,
241 index.spaces[&index.root].labels[&(ExGuid::default(), 1)],
242 )
243 .unwrap();
244 let root = &revision.objects[&revision.roots[&1]];
245 for (oid, (name, identity)) in root.references().unwrap().objects.iter().zip(entries) {
246 let ObjectData::Properties(data) = revision.objects[oid].data else {
247 panic!()
248 };
249 let props = PropertySets::parse(data).unwrap();
250 let expected: Vec<_> = name
251 .encode_utf16()
252 .chain([0])
253 .flat_map(u16::to_le_bytes)
254 .collect();
255 assert!(
256 props.sets[0]
257 .iter()
258 .any(|p| p.id == 0x1c001d94 && p.value == Value::Bytes(&identity))
259 );
260 assert!(
261 props.sets[0]
262 .iter()
263 .any(|p| p.id == 0x1c001d6b && p.value == Value::Bytes(&expected))
264 );
265 }
266 assert_eq!(root.references().unwrap().objects.len(), entries.len());
267 assert!(
268 onestore::create_table_of_contents(
269 "Open Notebook.onetoc2",
270 &[("same.one", [1; 16]), ("SAME.one", [2; 16])]
271 )
272 .is_err()
273 );
274 assert!(
275 onestore::create_table_of_contents(
276 "Open Notebook.onetoc2",
277 &[("a.one", [1; 16]), ("b.one", [1; 16])]
278 )
279 .is_err()
280 );
281 let empty = onestore::create_table_of_contents("Open Notebook.onetoc2", &[]).unwrap();
282 let store = Store::parse(&empty).unwrap();
283 RevisionIndex::parse(&store)
284 .unwrap()
285 .validate_current()
286 .unwrap();
287}
288
289fn assert_preserved_committed_bytes(source: &[u8], written: &[u8]) {
290 let before = Store::parse(source).unwrap();
291 let after = Store::parse(written).unwrap();
292 for node in before.lists.values().flat_map(|list| &list.nodes) {
293 let header = u32::from_le_bytes(source[node.offset..node.offset + 4].try_into().unwrap());
294 let size = ((header >> 10) & 0x1fff) as usize;
295 assert!(
296 source[node.offset..node.offset + size] == written[node.offset..node.offset + size],
297 "Committed node changed at {}",
298 node.offset
299 );
300 if let Some(onestore::Reference::Data(chunk)) = node.reference
301 && chunk.length > 0
302 {
303 assert_eq!(
304 before.chunk_data(chunk).unwrap(),
305 after.chunk_data(chunk).unwrap()
306 );
307 }
308 }
309 let mut uncommitted = written.to_vec();
310 uncommitted[..1024].copy_from_slice(&source[..1024]);
311 let old = Store::parse(&uncommitted).unwrap();
312 assert!(old.checksum_mismatches.is_empty());
313 RevisionIndex::parse(&old)
314 .unwrap()
315 .validate_current()
316 .unwrap();
317}
318
319#[test]
320fn toc_writes_cross_fragment_and_counter_boundaries_without_crc_drift() {
321 let mut bytes =
322 fs::read("../../corpus/m6/native-structure-01/notebook/Open Notebook.onetoc2").unwrap();
323 for i in 0_u32..260 {
324 let store = Store::parse(&bytes).unwrap();
325 assert!(store.checksum_mismatches.is_empty(), "edit {i}");
326 let index = RevisionIndex::parse(&store).unwrap();
327 index.validate_current().unwrap();
328 let written = recolored(&bytes, i);
329 assert_preserved_committed_bytes(&bytes, &written);
330 bytes = written;
331 }
332 let store = Store::parse(&bytes).unwrap();
333 assert!(store.checksum_mismatches.is_empty());
334 assert!(store.header.transaction_count >= 262);
335}
336
337#[test]
338fn checkpoints_bound_dependencies_and_preserve_native_objects_and_history() {
339 for (path, section) in [
340 (
341 "../../corpus/native/20260905-05/snapshots/06-attachment/notebook/synthetic.one",
342 true,
343 ),
344 (
345 "../../corpus/m6/native-structure-01/notebook/Open Notebook.onetoc2",
346 false,
347 ),
348 ] {
349 let mut bytes = fs::read(path).unwrap();
350 let initial = Store::parse(&bytes).unwrap();
351 let index = RevisionIndex::parse(&initial).unwrap();
352 // A section retypes text objects of one page; a table of contents recolours the notebook.
353 let document = Document::parse(&index).unwrap();
354 let mut candidates = Vec::new();
355 for (sid, view) in &document.spaces {
356 for (oid, node) in &view.active().unwrap().nodes {
357 match &node.kind {
358 Kind::RichText {
359 text,
360 boilerplate: false,
361 ..
362 } if section && !text.is_empty() => {
363 candidates.push((*sid, *oid));
364 }
365 Kind::Toc { entries, .. } if !section && !entries.is_empty() => {
366 candidates.push((*sid, *oid));
367 }
368 _ => {}
369 }
370 }
371 }
372 assert!(!candidates.is_empty());
373 let sid = candidates[0].0;
374 candidates.retain(|candidate| candidate.0 == sid);
375 // What each edit rewrites: modification times, or the notebook's colour.
376 let property = if section { 0x14001d7a } else { 0x14001cbe };
377 if section {
378 let revision = index
379 .resolve(sid, index.spaces[&sid].labels[&(ExGuid::default(), 1)])
380 .unwrap();
381 assert!(
382 revision
383 .objects
384 .values()
385 .any(|o| matches!(o.data, ObjectData::File { .. }))
386 );
387 assert!(revision.objects.values().any(|o| o.jcid & 0x100000 != 0));
388 }
389 let mut checkpoints = 0;
390 for operation in 0_u32..1025 {
391 let (_, oid) = candidates[operation as usize % candidates.len()];
392 let store = Store::parse(&bytes).unwrap();
393 let before = RevisionIndex::parse(&store).unwrap();
394 let rid = before.spaces[&sid].labels[&(ExGuid::default(), 1)];
395 let at = 134_000_000_000_000_000 + u64::from(operation) * 10_000_000;
396 let written = if section {
397 let typed = |range, with: &str| Op::Page {
398 space: sid,
399 op: PageOp::Text {
400 text: oid,
401 range,
402 with: with.into(),
403 },
404 };
405 // Modification times count seconds: each edit is a second later.
406 let ops = vec![typed(0..0, "x"), typed(0..1, "")];
407 let arena = onestore::Arena::default();
408 let mut section = onestore::Section::open(&arena, bytes.clone()).unwrap();
409 section.apply("Author", &Edit { at, ops }).unwrap();
410 section.seal().unwrap().unwrap();
411 section.image()
412 } else {
413 recolored(&bytes, 1_000_000 + operation)
414 };
415 let updated = Store::parse(&written).unwrap();
416 assert!(updated.checksum_mismatches.is_empty());
417 assert_eq!(
418 updated.header.transaction_count,
419 store.header.transaction_count + 1
420 );
421 let after = RevisionIndex::parse(&updated).unwrap();
422 after.validate_current().unwrap();
423 let next = after.spaces[&sid].labels[&(ExGuid::default(), 1)];
424 let depth =
425 std::iter::successors(Some(next), |id| after.spaces[&sid].revisions[id].dependency)
426 .count();
427 assert!(depth <= 512);
428 if after.spaces[&sid].revisions[&next].dependency.is_none() {
429 checkpoints += 1;
430 assert_preserved_committed_bytes(&bytes, &written);
431 let old = before.resolve(sid, rid).unwrap();
432 let current = after.resolve(sid, next).unwrap();
433 if section {
434 assert_eq!(metadata::check(&old, &current, "Author"), at);
435 }
436 assert_eq!(old.roots, current.roots);
437 assert_eq!(old.objects.len(), current.objects.len());
438 for (id, object) in &old.objects {
439 let same = &current.objects[id];
440 assert_eq!(object.jcid, same.jcid);
441 assert_eq!(object.reference_count, same.reference_count);
442 let a = object.references().unwrap();
443 let b = same.references().unwrap();
444 assert_eq!(a.objects, b.objects);
445 assert_eq!(a.object_spaces, b.object_spaces);
446 assert_eq!(a.contexts, b.contexts);
447 if section {
448 assert!(
449 same.global_ids
450 .iter()
451 .all(|(entry, guid)| object.global_ids.get(entry) == Some(guid))
452 );
453 }
454 if let (ObjectData::Properties(a), ObjectData::Properties(b)) =
455 (object.data, same.data)
456 {
457 let a = PropertySets::parse(a).unwrap();
458 let b = PropertySets::parse(b).unwrap();
459 assert_eq!(a.sets.len(), b.sets.len());
460 for (a, b) in a.sets.iter().zip(&b.sets) {
461 assert_eq!(a.len(), b.len());
462 for (a, b) in a.iter().zip(b) {
463 assert_eq!(a.id, b.id);
464 if a.id != property
465 && !(section
466 && Some(id) == old.roots.get(&4)
467 && a.id == 0x18001d77)
468 && !matches!(a.value, Value::References { .. })
469 {
470 assert_eq!(a.value, b.value, "{id} {:#x}", a.id);
471 }
472 }
473 }
474 }
475 }
476 for (space_id, space) in &before.spaces {
477 for previous in space.revisions.keys() {
478 let old = before.resolve(*space_id, *previous).unwrap();
479 let preserved = after.resolve(*space_id, *previous).unwrap();
480 assert_eq!(old.roots, preserved.roots);
481 assert_eq!(old.objects.len(), preserved.objects.len());
482 for (id, object) in old.objects {
483 let same = &preserved.objects[&id];
484 assert_eq!(object.jcid, same.jcid);
485 assert_eq!(object.reference_count, same.reference_count);
486 assert!(
487 same.global_ids
488 .iter()
489 .all(|(entry, guid)| object.global_ids.get(entry) == Some(guid))
490 );
491 assert_eq!(object.data, same.data);
492 }
493 }
494 }
495 }
496 bytes = written;
497 }
498 assert_eq!(checkpoints, 2, "{path}");
499 }
500}