| 1 | #[path = "support/disk.rs"] |
| 2 | mod disk; |
| 3 | use disk::Disk; |
| 4 | use onestore::{ |
| 5 | Arena, ExGuid, RevisionIndex, Section, Store, Transaction, |
| 6 | document::{Document, Kind}, |
| 7 | op::{Edit, Op, PageOp}, |
| 8 | }; |
| 9 | use std::fs; |
| 10 | |
| 11 | #[test] |
| 12 | fn 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. |
| 25 | fn 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. |
| 42 | fn 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] |
| 57 | fn 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 | |
| 145 | fn 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))] |
| 184 | fn 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. |
| 222 | fn 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] |
| 232 | fn 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 | } |