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