| 1 | #![no_main] |
| 2 | use libfuzzer_sys::fuzz_target; |
| 3 | use onestore::{ |
| 4 | CommitState, ExGuid, RevisionIndex, Store, |
| 5 | document::{Document, Kind}, |
| 6 | }; |
| 7 | use std::sync::LazyLock; |
| 8 | |
| 9 | #[path = "../../crates/onestore/tests/support/checkpoint.rs"] |
| 10 | mod checkpoint; |
| 11 | #[path = "../../crates/onestore/tests/support/disk.rs"] |
| 12 | mod disk; |
| 13 | #[path = "../../crates/onestore/tests/support/ops.rs"] |
| 14 | mod ops; |
| 15 | use disk::Disk; |
| 16 | use onestore::op::{Op, PageOp}; |
| 17 | |
| 18 | const SOURCE: &[u8] = include_bytes!( |
| 19 | "../../corpus/native/20260905-05/snapshots/03-format-unicode/notebook/synthetic.one" |
| 20 | ); |
| 21 | static 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 | |
| 73 | fn 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 | |
| 131 | fuzz_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 | }); |