| 1 | #[path = "../tests/support/current.rs"] |
| 2 | mod current; |
| 3 | use current::current; |
| 4 | |
| 5 | #[path = "../tests/support/trace.rs"] |
| 6 | mod trace; |
| 7 | use trace::{Event, Trace}; |
| 8 | |
| 9 | #[path = "../tests/support/checkpoint.rs"] |
| 10 | mod checkpoint; |
| 11 | #[path = "support/typing.rs"] |
| 12 | mod typing; |
| 13 | |
| 14 | use onestore::{ |
| 15 | ExGuid, RevisionIndex, Store, |
| 16 | document::{Document, Kind}, |
| 17 | }; |
| 18 | use std::{collections::BTreeMap, fs, path::PathBuf}; |
| 19 | |
| 20 | /// `source`, a table of contents, with the notebook recoloured to `color`. |
| 21 | fn recolored(source: &[u8], color: u32) -> Result<Vec<u8>, onestore::Error> { |
| 22 | let mut image = source.to_vec(); |
| 23 | let edit = onestore::TocEdit::Color(color); |
| 24 | if let Some(transaction) = onestore::edit_table_of_contents(source, &[edit])? { |
| 25 | transaction.apply(&mut image)?; |
| 26 | } |
| 27 | Ok(image) |
| 28 | } |
| 29 | |
| 30 | fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 31 | let mut args = std::env::args_os().skip(1); |
| 32 | let destination = PathBuf::from(args.next().ok_or("Provide a new evidence directory")?); |
| 33 | let option = args.next(); |
| 34 | if option.as_deref().is_some_and(|flag| flag != "--toc") || args.next().is_some() { |
| 35 | return Err("Usage: power_loss NEW_DIRECTORY [--toc]".into()); |
| 36 | } |
| 37 | fs::create_dir(&destination)?; |
| 38 | let fixtures = [ |
| 39 | ( |
| 40 | "unicode", |
| 41 | "corpus/native/20260905-05/snapshots/03-format-unicode/notebook/synthetic.one", |
| 42 | ), |
| 43 | ( |
| 44 | "rollover-256", |
| 45 | "corpus/append/round-01/tx-255/notebook/synthetic.one", |
| 46 | ), |
| 47 | ( |
| 48 | "rollover-65536", |
| 49 | "corpus/append/round-01/tx-65535/notebook/synthetic.one", |
| 50 | ), |
| 51 | ( |
| 52 | "attachment", |
| 53 | "corpus/native/20260905-05/snapshots/06-attachment/notebook/synthetic.one", |
| 54 | ), |
| 55 | ( |
| 56 | "checkpoint", |
| 57 | "corpus/native/20260905-05/snapshots/03-format-unicode/notebook/synthetic.one", |
| 58 | ), |
| 59 | ( |
| 60 | "toc", |
| 61 | "corpus/native/20260905-05/snapshots/02-text/notebook/Open Notebook.onetoc2", |
| 62 | ), |
| 63 | ( |
| 64 | "toc-rollover", |
| 65 | "corpus/native/20260905-05/snapshots/02-text/notebook/Open Notebook.onetoc2", |
| 66 | ), |
| 67 | ( |
| 68 | "toc-checkpoint", |
| 69 | "corpus/native/20260905-05/snapshots/02-text/notebook/Open Notebook.onetoc2", |
| 70 | ), |
| 71 | ]; |
| 72 | let mut records = Vec::new(); |
| 73 | let mut saved = BTreeMap::new(); |
| 74 | for (name, path) in fixtures { |
| 75 | if option.is_some() && !name.starts_with("toc") { |
| 76 | continue; |
| 77 | } |
| 78 | println!("Checking {name}"); |
| 79 | let mut source = fs::read(path)?; |
| 80 | let store = Store::parse(&source)?; |
| 81 | let index = RevisionIndex::parse(&store)?; |
| 82 | let document = Document::parse(&index)?; |
| 83 | let toc = store.header.file_type == onestore::FileType::TableOfContents; |
| 84 | // A table of contents changes the notebook's colour. |
| 85 | let (sid, oid) = if toc { |
| 86 | let root = &document.spaces[&document.root]; |
| 87 | let root = &root.revisions[&root.contexts[&ExGuid::default()]]; |
| 88 | (document.root, root.roots[&1]) |
| 89 | } else { |
| 90 | document |
| 91 | .spaces |
| 92 | .iter() |
| 93 | .find_map(|(sid, space)| { |
| 94 | let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 95 | revision |
| 96 | .nodes |
| 97 | .iter() |
| 98 | .find_map(|(oid, node)| match &node.kind { |
| 99 | Kind::RichText { text, .. } |
| 100 | if text.starts_with("Fictitious") || text == "Transaction 255" => |
| 101 | { |
| 102 | Some((*sid, *oid)) |
| 103 | } |
| 104 | _ => None, |
| 105 | }) |
| 106 | }) |
| 107 | .ok_or("Missing native fixture text")? |
| 108 | }; |
| 109 | match name { |
| 110 | "checkpoint" => source = checkpoint::pending(&source, sid, oid), |
| 111 | "toc-checkpoint" => { |
| 112 | for value in 0.. { |
| 113 | let store = Store::parse(&source)?; |
| 114 | let index = RevisionIndex::parse(&store)?; |
| 115 | let rid = index.spaces[&sid].labels[&(ExGuid::default(), 1)]; |
| 116 | let depth = std::iter::successors(Some(rid), |id| { |
| 117 | index.spaces[&sid].revisions[id].dependency |
| 118 | }) |
| 119 | .count(); |
| 120 | if depth == 512 { |
| 121 | break; |
| 122 | } |
| 123 | source = recolored(&source, value)?; |
| 124 | } |
| 125 | } |
| 126 | "toc-rollover" => loop { |
| 127 | let count = Store::parse(&source)?.header.transaction_count; |
| 128 | if count == 255 { |
| 129 | break; |
| 130 | } |
| 131 | assert!(count < 255); |
| 132 | source = recolored(&source, count)?; |
| 133 | }, |
| 134 | _ => {} |
| 135 | } |
| 136 | let apply = |io: &mut Trace, |
| 137 | bytes: &[u8], |
| 138 | recovering: bool| |
| 139 | -> Result<(), Box<dyn std::error::Error>> { |
| 140 | if toc { |
| 141 | let color = if recovering { 0x665544 } else { 0x996633 }; |
| 142 | let edit = onestore::TocEdit::Color(color); |
| 143 | let transaction = |
| 144 | onestore::edit_table_of_contents(bytes, &[edit])?.ok_or("No change")?; |
| 145 | transaction.commit(io)?; |
| 146 | } else { |
| 147 | let store = Store::parse(bytes)?; |
| 148 | let index = RevisionIndex::parse(&store)?; |
| 149 | let doc = Document::parse(&index)?; |
| 150 | let space = &doc.spaces[&sid]; |
| 151 | let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 152 | let Kind::RichText { text, .. } = &revision.nodes[&oid].kind else { |
| 153 | unreachable!() |
| 154 | }; |
| 155 | let end = u32::try_from(text.encode_utf16().count())?; |
| 156 | let with = if recovering { |
| 157 | " [recovered]" |
| 158 | } else { |
| 159 | " [durable café 🦀]" |
| 160 | }; |
| 161 | let edit = typing::text(sid, oid, end..end, with); |
| 162 | let transaction = typing::sealed(bytes, "Power loss", &edit)?.ok_or("No change")?; |
| 163 | transaction.commit(io)?; |
| 164 | } |
| 165 | Ok(()) |
| 166 | }; |
| 167 | fs::write(destination.join(format!("{name}-source.bin")), &source)?; |
| 168 | let before = current(&source); |
| 169 | let mut trace = Trace { |
| 170 | bytes: source.clone(), |
| 171 | events: Vec::new(), |
| 172 | }; |
| 173 | apply(&mut trace, &source, false)?; |
| 174 | let after = current(&trace.bytes); |
| 175 | assert_ne!(before, after); |
| 176 | let mut durable = source.clone(); |
| 177 | let mut pending = Vec::new(); |
| 178 | let mut flushes = 0; |
| 179 | for (step, event) in trace.events.iter().enumerate() { |
| 180 | if let Event::Write(offset, data) = event { |
| 181 | pending.push((*offset, data)); |
| 182 | } |
| 183 | for policy in 0..6 { |
| 184 | let mut image = durable.clone(); |
| 185 | // Unflushed writes may reach storage out of order and only in part. |
| 186 | for (offset, data) in pending.iter().rev() { |
| 187 | for (i, byte) in data.iter().enumerate() { |
| 188 | let persist = match policy { |
| 189 | 0 => false, |
| 190 | 1 => true, |
| 191 | 2 => i < data.len() / 2, |
| 192 | 3 => i >= data.len() / 2, |
| 193 | 4 => ((offset + i) / 512) % 2 == 0, |
| 194 | _ => (offset + i).wrapping_mul(0x9e3779b9).count_ones() % 2 == 0, |
| 195 | }; |
| 196 | if persist { |
| 197 | image.resize(image.len().max(offset + i + 1), 0); |
| 198 | image[offset + i] = *byte; |
| 199 | } |
| 200 | } |
| 201 | } |
| 202 | let checked = |
| 203 | std::panic::catch_unwind(|| -> Result<bool, Box<dyn std::error::Error>> { |
| 204 | let observed = current(&image); |
| 205 | assert!( |
| 206 | observed == before || observed == after, |
| 207 | "{name} step {step} policy {policy}" |
| 208 | ); |
| 209 | if flushes >= 3 { |
| 210 | assert_eq!(observed, after, "Acknowledged publication was lost"); |
| 211 | } |
| 212 | let committed = observed == after; |
| 213 | let mut recovered = Trace { |
| 214 | bytes: image.clone(), |
| 215 | events: Vec::new(), |
| 216 | }; |
| 217 | apply(&mut recovered, &image, true)?; |
| 218 | let recovered_store = Store::parse(&recovered.bytes)?; |
| 219 | let recovered_index = RevisionIndex::parse(&recovered_store)?; |
| 220 | let recovered_doc = Document::parse(&recovered_index)?; |
| 221 | let space = &recovered_doc.spaces[&sid]; |
| 222 | let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 223 | match &revision.nodes[&oid].kind { |
| 224 | Kind::RichText { text, .. } => assert!(text.ends_with(" [recovered]")), |
| 225 | Kind::Toc { color, .. } => assert_eq!(*color, Some(0x665544)), |
| 226 | _ => unreachable!(), |
| 227 | } |
| 228 | Ok(committed) |
| 229 | }); |
| 230 | let committed = match checked { |
| 231 | Ok(Ok(committed)) => committed, |
| 232 | failure => { |
| 233 | fs::write(destination.join("failure.one"), &image)?; |
| 234 | fs::write( |
| 235 | destination.join("failure.json"), |
| 236 | serde_json::to_vec_pretty( |
| 237 | &serde_json::json!({"case": name, "step": step, "policy": policy, "completed_flushes": flushes}), |
| 238 | )?, |
| 239 | )?; |
| 240 | match failure { |
| 241 | Ok(Err(error)) => return Err(error), |
| 242 | Err(panic) => std::panic::resume_unwind(panic), |
| 243 | _ => unreachable!(), |
| 244 | } |
| 245 | } |
| 246 | }; |
| 247 | let digest = format!("{:x}", md5::compute(&image)); |
| 248 | if matches!(event, Event::Flush) |
| 249 | && (policy == 0 || policy == 1) |
| 250 | && !saved.contains_key(&digest) |
| 251 | { |
| 252 | let directory = format!("{name}-{step}-{policy}"); |
| 253 | let notebook = destination.join(&directory).join("notebook"); |
| 254 | fs::create_dir_all(&notebook)?; |
| 255 | if toc { |
| 256 | fs::write(notebook.join("Open Notebook.onetoc2"), &image)?; |
| 257 | fs::copy( |
| 258 | PathBuf::from(path).parent().unwrap().join("synthetic.one"), |
| 259 | notebook.join("synthetic.one"), |
| 260 | )?; |
| 261 | } else { |
| 262 | fs::write(notebook.join("synthetic.one"), &image)?; |
| 263 | let identity = Store::parse(&image)?.header.file_id; |
| 264 | fs::write( |
| 265 | notebook.join("Open Notebook.onetoc2"), |
| 266 | onestore::create_table_of_contents( |
| 267 | "Open Notebook.onetoc2", |
| 268 | &[("synthetic.one", identity)], |
| 269 | )?, |
| 270 | )?; |
| 271 | } |
| 272 | saved.insert(digest.clone(), directory); |
| 273 | } |
| 274 | records.push(serde_json::json!({"case":name,"step":step,"completed_flushes":flushes,"policy":policy,"committed":committed,"md5":digest})); |
| 275 | } |
| 276 | if matches!(event, Event::Flush) { |
| 277 | for (offset, data) in pending.drain(..) { |
| 278 | durable.resize(durable.len().max(offset + data.len()), 0); |
| 279 | durable[offset..offset + data.len()].copy_from_slice(data); |
| 280 | } |
| 281 | flushes += 1; |
| 282 | current(&durable); |
| 283 | } |
| 284 | } |
| 285 | assert_eq!(current(&durable), after); |
| 286 | } |
| 287 | fs::write( |
| 288 | destination.join("matrix.json"), |
| 289 | serde_json::to_vec_pretty(&records)?, |
| 290 | )?; |
| 291 | fs::write( |
| 292 | destination.join("native-cases.json"), |
| 293 | serde_json::to_vec_pretty(&saved)?, |
| 294 | )?; |
| 295 | println!( |
| 296 | "Passed {} persisted images and subsequent edits; {} native cases retained", |
| 297 | records.len(), |
| 298 | saved.len() |
| 299 | ); |
| 300 | Ok(()) |
| 301 | } |