1#![no_main]
2use libfuzzer_sys::fuzz_target;
3use onestore::{
4 CommitState, ExGuid, RevisionIndex, Store,
5 document::{Document, Kind},
6};
7use std::sync::LazyLock;
8
9#[path = "../../crates/onestore/tests/support/checkpoint.rs"]
10mod checkpoint;
11#[path = "../../crates/onestore/tests/support/disk.rs"]
12mod disk;
13#[path = "../../crates/onestore/tests/support/ops.rs"]
14mod ops;
15use disk::Disk;
16use onestore::op::{Op, PageOp};
17
18const SOURCE: &[u8] = include_bytes!(
19 "../../corpus/native/20260905-05/snapshots/03-format-unicode/notebook/synthetic.one"
20);
21static CASES: LazyLock<[(Vec<u8>, ExGuid, ExGuid); 7]> = LazyLock::new(|| {
22 let fixtures: [(&[u8], &str); 6] = [
23 (SOURCE, "Fictitious"),
24 (
25 include_bytes!(
26 "../../corpus/native/20260905-05/snapshots/02-text/notebook/synthetic.one"
27 ),
28 "Fictitious",
29 ),
30 (
31 include_bytes!("../../corpus/m6/native-empty-link-01/notebook/synthetic.one"),
32 "",
33 ),
34 (
35 include_bytes!("../../corpus/m6/native-structure-01/notebook/synthetic.one"),
36 "Native feature probes",
37 ),
38 (
39 include_bytes!("../../corpus/m7/automatic-titles/widths-and-limits.one"),
40 "Right narrow.",
41 ),
42 (
43 include_bytes!("../../corpus/m7/automatic-titles/widths-and-limits.one"),
44 "Left wide.",
45 ),
46 ];
47 let [ordinary, legacy, empty, title, rtl_title, rtl_body] = fixtures.map(|(source, prefix)| {
48 let store = Store::parse(source).unwrap();
49 let index = RevisionIndex::parse(&store).unwrap();
50 let document = Document::parse(&index).unwrap();
51 for (sid, space) in &document.spaces {
52 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
53 for (oid, node) in &revision.nodes {
54 if matches!(&node.kind, Kind::RichText { text, boilerplate: false, .. }
55 if if prefix.is_empty() { text.is_empty() } else { text.starts_with(prefix) })
56 {
57 return (source.to_vec(), *sid, *oid);
58 }
59 }
60 }
61 panic!("Missing text fixture")
62 });
63 let checkpoint = (
64 checkpoint::pending(&ordinary.0, ordinary.1, ordinary.2),
65 ordinary.1,
66 ordinary.2,
67 );
68 [
69 ordinary, legacy, empty, checkpoint, title, rtl_title, rtl_body,
70 ]
71});
72
73fn characters(source: &[u8], sid: ExGuid, oid: ExGuid) -> Vec<(char, String)> {
74 let store = Store::parse(source).unwrap();
75 assert!(store.checksum_mismatches.is_empty());
76 let index = RevisionIndex::parse(&store).unwrap();
77 index.validate_current().unwrap();
78 let document = Document::parse(&index).unwrap();
79 let space = &document.spaces[&sid];
80 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
81 let node = &revision.nodes[&oid];
82 if node.extra[0].iter().any(|field| field.id == 0x88001cb4) {
83 let Kind::RichText { text, .. } = &node.kind else {
84 panic!()
85 };
86 let text = text.trim_start().split('\r').next().unwrap();
87 let Kind::Metadata { title, .. } = &revision.nodes[&revision.roots[&2]].kind else {
88 panic!()
89 };
90 let pages: Vec<_> = document
91 .pages()
92 .unwrap()
93 .into_iter()
94 .filter(|(id, _)| *id == sid)
95 .collect();
96 let [(_, page)] = pages.as_slice() else {
97 panic!()
98 };
99 let Kind::Page {
100 alternate_title, ..
101 } = &revision.nodes[page].kind
102 else {
103 panic!()
104 };
105 assert_eq!(
106 title.as_deref().unwrap(),
107 if text.is_empty() {
108 alternate_title.as_deref().unwrap_or("")
109 } else {
110 text
111 }
112 );
113 if !text.is_empty() {
114 assert!(alternate_title.as_deref().unwrap_or("").is_empty());
115 }
116 }
117 let runs = revision.text_runs(oid).unwrap();
118 let mut characters: Vec<_> = runs
119 .iter()
120 .flat_map(|run| {
121 run.text
122 .chars()
123 .map(move |c| (c, format!("{:?}", run.format)))
124 })
125 .collect();
126 // The terminal marker retains insertion formatting when the final run is empty.
127 characters.push(('\0', format!("{:?}", runs.last().unwrap().format)));
128 characters
129}
130
131fuzz_target!(|input: &[u8]| {
132 let (source, sid, oid) = &CASES[usize::from(input.last().copied().unwrap_or(0)) % CASES.len()];
133 let (sid, oid) = (*sid, *oid);
134 let mut persisted_source = source.clone();
135 let mut caches = std::array::from_fn::<_, 12, _>(|_| source.clone());
136 for step in input.chunks_exact(8).take(16) {
137 let actor = usize::from(step[6]) % caches.len();
138 if step[7] % 3 == 0 {
139 caches[actor].clone_from(&persisted_source);
140 continue;
141 }
142 let source = &caches[actor];
143 let current = characters(&persisted_source, sid, oid);
144 let before = characters(source, sid, oid);
145 let a = usize::from(step[0]) % before.len();
146 let b = usize::from(step[1]) % before.len();
147 let start = a.min(b);
148 let end = a.max(b);
149 let offset = before[..start]
150 .iter()
151 .map(|(c, _)| c.len_utf16() as u32)
152 .sum::<u32>();
153 let removed = before[start..end]
154 .iter()
155 .map(|(c, _)| c.len_utf16() as u32)
156 .sum::<u32>();
157 let replacement = ["", "a", "🦀e\u{301}", "日本語", "\n", "\0"][usize::from(step[2]) % 6];
158 let mut expected = before.clone();
159 let format = before[start].1.clone();
160 if before[start..end]
161 .iter()
162 .map(|(c, _)| *c)
163 .collect::<String>()
164 != replacement
165 {
166 expected.splice(start..end, replacement.chars().map(|c| (c, format.clone())));
167 }
168 let mut storage = Disk {
169 visible: persisted_source.clone(),
170 durable: persisted_source.clone(),
171 operation: 0,
172 fail_at: (step[3] & 1 != 0).then_some(usize::from(step[4]) + 1),
173 write_limit: usize::from(step[5]) + 1,
174 random: u64::from_le_bytes(step.try_into().unwrap()),
175 };
176 let op = PageOp::Text {
177 text: oid,
178 range: offset..offset + removed,
179 with: replacement.to_owned(),
180 };
181 let transaction = ops::transaction(source, "Fuzz", vec![Op::Page { space: sid, op }]);
182 if !replacement.contains(['\n', '\0']) {
183 assert!(
184 transaction.is_ok(),
185 "Valid ordinary text edit was rejected: {transaction:?}"
186 );
187 }
188 let result = match transaction {
189 Ok(Some(transaction)) => transaction.commit(&mut storage),
190 Ok(None) | Err(_) => {
191 assert_eq!(characters(&storage.durable, sid, oid), current);
192 continue;
193 }
194 };
195 let persisted = characters(&storage.durable, sid, oid);
196 match result {
197 Ok(()) => assert_eq!(persisted, expected),
198 Err(error) => match error.state {
199 CommitState::NotCommitted => assert_eq!(persisted, current),
200 CommitState::Committed => assert_eq!(persisted, expected),
201 CommitState::Unknown => assert!(persisted == current || persisted == expected),
202 },
203 }
204 persisted_source = storage.durable;
205 }
206});