1#[path = "support/disk.rs"]
2mod disk;
3use disk::Disk;
4use onestore::{
5 Arena, ExGuid, RevisionIndex, Section, Store, Transaction,
6 document::{Document, Kind},
7 op::{Edit, Op, PageOp},
8};
9use std::fs;
10
11#[test]
12fn incomplete_native_save_is_rejected_before_an_edit() {
13 let source = fs::read("../../corpus/malformed/native-inflight.one").unwrap();
14 let store = Store::parse(&source).unwrap();
15 let graph_error = RevisionIndex::parse(&store)
16 .unwrap()
17 .validate_current()
18 .unwrap_err();
19 let arena = Arena::default();
20 let error = Section::open(&arena, source.clone()).err().unwrap();
21 assert_eq!(error.to_string(), graph_error.to_string());
22}
23
24/// The text object holding `text` and its page space.
25fn target(source: &[u8], text: &str) -> (ExGuid, ExGuid) {
26 let store = Store::parse(source).unwrap();
27 let index = RevisionIndex::parse(&store).unwrap();
28 let document = Document::parse(&index).unwrap();
29 document
30 .spaces
31 .iter()
32 .find_map(|(space, view)| {
33 view.active()?.nodes.iter().find_map(|(id, node)| {
34 matches!(&node.kind, Kind::RichText { text: stored, .. } if stored == text)
35 .then_some((*space, *id))
36 })
37 })
38 .unwrap()
39}
40
41/// The text `object` holds in `image`, which must be valid.
42fn text_of(image: &[u8], space: ExGuid, object: ExGuid) -> String {
43 let store = Store::parse(image).unwrap();
44 assert!(store.checksum_mismatches.is_empty());
45 let index = RevisionIndex::parse(&store).unwrap();
46 index.validate_current().unwrap();
47 let document = Document::parse(&index).unwrap();
48 let Kind::RichText { text, .. } =
49 &document.spaces[&space].active().unwrap().nodes[&object].kind
50 else {
51 panic!()
52 };
53 text.clone()
54}
55
56#[test]
57fn repeated_appends_cross_log_fragments_and_expose_counter_tears() {
58 let initial =
59 fs::read("../../corpus/native/20260905-05/snapshots/02-text/notebook/synthetic.one")
60 .unwrap();
61 let store = Store::parse(&initial).unwrap();
62 let (space, object) = target(&initial, "Fictitious plain text.");
63 let root = store.header.root;
64 let log = store.header.transaction_log;
65 let initial_count = store.header.transaction_count;
66 let arena = Arena::default();
67 let mut section = Section::open(&arena, initial.clone()).unwrap();
68 let mut source = initial.clone();
69 let mut length = "Fictitious plain text.".encode_utf16().count() as u32;
70 let mut saw_counter_tear = false;
71 for count in initial_count + 1..=258 {
72 let value = format!("Transaction {count}");
73 let op = PageOp::Text {
74 text: object,
75 range: 0..length,
76 with: value.clone(),
77 };
78 length = value.encode_utf16().count() as u32;
79 section
80 .apply(
81 "Author",
82 &Edit {
83 at: 133_700_000_000_000_000 + u64::from(count),
84 ops: vec![Op::Page { space, op }],
85 },
86 )
87 .unwrap();
88 let transaction = section.seal().unwrap().unwrap();
89 let mut written = source.clone();
90 transaction.apply(&mut written).unwrap();
91 let store = Store::parse(&written).unwrap();
92 assert_eq!(store.header.root, root);
93 assert_eq!(store.header.transaction_log, log);
94 assert_eq!(store.header.transaction_count, count);
95 assert_eq!(text_of(&written, space, object), value);
96 let mut previous = written.clone();
97 previous[..1024].copy_from_slice(&source[..1024]);
98 let old = Store::parse(&previous).unwrap();
99 assert_eq!(old.header.transaction_count, count - 1);
100 assert!(old.checksum_mismatches.is_empty());
101 RevisionIndex::parse(&old)
102 .unwrap()
103 .validate_current()
104 .unwrap();
105 for prefix in 96..=100 {
106 let mut torn = written.clone();
107 torn[prefix..1024].copy_from_slice(&source[prefix..1024]);
108 let result = Store::parse(&torn)
109 .and_then(|store| RevisionIndex::parse(&store)?.validate_current());
110 if count == 256 && prefix == 97 {
111 assert!(result.is_err());
112 saw_counter_tear = true;
113 } else {
114 result.unwrap();
115 }
116 }
117 if count == initial_count + 1 || count == 256 {
118 check_crashes(&source, &transaction, space, object, &value);
119 }
120 if count == initial_count + 1 {
121 let path = std::env::temp_dir().join(format!(
122 "onestore-commit-{}-{}.one",
123 std::process::id(),
124 std::time::SystemTime::now()
125 .duration_since(std::time::UNIX_EPOCH)
126 .unwrap()
127 .as_nanos()
128 ));
129 fs::write(&path, &source).unwrap();
130 transaction.commit_file(&path).unwrap();
131 let committed = fs::read(&path).unwrap();
132 assert_eq!(committed, written);
133 // The file moved past the transaction's base: nothing is written again.
134 let stale = transaction.commit_file(&path).unwrap_err();
135 assert_eq!(stale.state, onestore::CommitState::NotCommitted);
136 assert_eq!(fs::read(&path).unwrap(), committed);
137 fs::remove_file(path).unwrap();
138 }
139 source = written;
140 }
141 assert!(saw_counter_tear);
142 assert!(section.image() == source);
143}
144
145fn check_crashes(
146 source: &[u8],
147 transaction: &Transaction,
148 space: ExGuid,
149 object: ExGuid,
150 value: &str,
151) {
152 let before = text_of(source, space, object);
153 for limit in [17, 1024] {
154 let disk = |fail_at, random| Disk {
155 visible: source.to_vec(),
156 durable: source.to_vec(),
157 operation: 0,
158 fail_at,
159 write_limit: limit,
160 random,
161 };
162 let mut successful = disk(None, 1);
163 transaction.commit(&mut successful).unwrap();
164 assert_eq!(text_of(&successful.durable, space, object), value);
165 for at in 1..=successful.operation {
166 for seed in [0, 1, 42, u64::MAX] {
167 let mut interrupted = disk(Some(at), seed);
168 let failure = transaction.commit(&mut interrupted).unwrap_err();
169 let persisted = text_of(&interrupted.durable, space, object);
170 match failure.state {
171 onestore::CommitState::NotCommitted => assert_eq!(persisted, before),
172 onestore::CommitState::Committed => assert_eq!(persisted, value),
173 onestore::CommitState::Unknown => {
174 assert!(persisted == before || persisted == value)
175 }
176 }
177 }
178 }
179 }
180}
181
182#[test]
183#[cfg(any(unix, windows))]
184fn bounded_file_reads_reject_partial_images_and_release_the_owner() {
185 use std::io::Write;
186 let path = std::env::temp_dir().join(format!(
187 "onestore-bounded-{}-{}",
188 std::process::id(),
189 std::time::SystemTime::now()
190 .duration_since(std::time::UNIX_EPOCH)
191 .unwrap()
192 .as_nanos()
193 ));
194 let mut file = fs::File::options()
195 .write(true)
196 .create_new(true)
197 .open(&path)
198 .unwrap();
199 assert!(onestore::read_file_limited(&path, 0).unwrap().is_empty());
200 let bytes: Vec<_> = (0..10_000).map(|i| (i % 251) as u8).collect();
201 file.write_all(&bytes).unwrap();
202 drop(file);
203 for limit in [0, 1, 100, bytes.len() - 1] {
204 assert_eq!(
205 onestore::read_file_limited(&path, limit)
206 .unwrap_err()
207 .kind(),
208 std::io::ErrorKind::FileTooLarge
209 );
210 assert_eq!(
211 onestore::read_file_limited(&path, bytes.len()).unwrap(),
212 bytes
213 );
214 }
215 assert_eq!(onestore::read_file(&path).unwrap(), bytes);
216 assert_eq!(fs::read(&path).unwrap(), bytes);
217 fs::remove_file(path).unwrap();
218}
219
220/// Zeroes the chunk reference of the file node at `offset`, as OneNote's garbage
221/// collection leaves a node whose target it freed.
222fn free_reference(bytes: &mut [u8], offset: usize) {
223 let header = u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap());
224 let stp = [8, 4, 2, 4][(header >> 23 & 3) as usize];
225 let cb = [4, 8, 1, 2][(header >> 25 & 3) as usize];
226 bytes[offset + 4..offset + 4 + stp + cb].fill(0);
227}
228
229/// OneNote's compaction frees deleted pages' object spaces and unused file data, zeroing
230/// the root list's and file-data store's references to them in place.
231#[test]
232fn garbage_collected_references_read_as_absent_and_accept_appends() {
233 let source = fs::read(
234 "../../corpus/page-lifecycle/removal/features/native/after/notebook/Lifecycle.one",
235 )
236 .unwrap();
237 let store = Store::parse(&source).unwrap();
238 let index = RevisionIndex::parse(&store).unwrap();
239 let document = Document::parse(&index).unwrap();
240 let pages = document.pages().unwrap();
241 let (live, deleted): (Vec<_>, Vec<_>) = index
242 .spaces
243 .keys()
244 .partition(|space| **space == index.root || pages.iter().any(|(page, _)| page == *space));
245 let mut referenced = std::collections::BTreeSet::new();
246 for space in live {
247 for rid in index.spaces[space].revisions.keys() {
248 for object in index.resolve(*space, *rid).unwrap().objects.values() {
249 if let Some(onestore::FileDataReference::Internal(guid)) =
250 object.file_reference().unwrap()
251 {
252 referenced.insert(guid);
253 }
254 }
255 }
256 }
257 let mut collected = source.clone();
258 let mut orphans = Vec::new();
259 for node in store.lists.values().flat_map(|list| &list.nodes) {
260 let freed = match node.id {
261 8 => deleted.iter().any(|space| {
262 node.payload[node.payload.len() - 20..]
263 == [space.guid.as_slice(), &space.n.to_le_bytes()].concat()
264 }),
265 0x94 => {
266 let guid: [u8; 16] = node.payload[..16].try_into().unwrap();
267 !referenced.contains(&guid) && {
268 orphans.push(guid);
269 true
270 }
271 }
272 _ => false,
273 };
274 if freed {
275 free_reference(&mut collected, node.offset);
276 }
277 }
278 assert!(!deleted.is_empty() && !orphans.is_empty());
279 let store = Store::parse(&collected).unwrap();
280 let index = RevisionIndex::parse(&store).unwrap();
281 index.validate_current().unwrap();
282 assert!(
283 deleted
284 .iter()
285 .all(|space| !index.spaces.contains_key(space))
286 );
287 assert_eq!(Document::parse(&index).unwrap().pages().unwrap(), pages);
288 for guid in orphans {
289 assert_eq!(
290 store.file_data(guid).unwrap_err().message,
291 "File-data object is not declared"
292 );
293 }
294
295 let arena = Arena::default();
296 let (space, object, transaction) = document
297 .spaces
298 .iter()
299 .flat_map(|(space, view)| {
300 let nodes = view.active().map(|revision| &revision.nodes);
301 nodes
302 .into_iter()
303 .flatten()
304 .filter_map(|(id, node)| match &node.kind {
305 Kind::RichText { text, .. } => Some((*space, *id, text.encode_utf16().count())),
306 _ => None,
307 })
308 })
309 .find_map(|(space, object, length)| {
310 let mut section = Section::open(&arena, collected.clone()).unwrap();
311 let op = PageOp::Text {
312 text: object,
313 range: 0..length as u32,
314 with: "After collection".into(),
315 };
316 let edit = Edit {
317 at: 133_700_000_000_000_000,
318 ops: vec![Op::Page { space, op }],
319 };
320 section.apply("Author", &edit).ok()?;
321 Some((space, object, section.seal().unwrap().unwrap()))
322 })
323 .unwrap();
324 let mut written = collected;
325 transaction.apply(&mut written).unwrap();
326 assert_eq!(text_of(&written, space, object), "After collection");
327}