1#[path = "../tests/support/current.rs"]
2mod current;
3use current::current;
4
5#[path = "../tests/support/trace.rs"]
6mod trace;
7use trace::{Event, Trace};
8
9#[path = "../tests/support/checkpoint.rs"]
10mod checkpoint;
11#[path = "support/typing.rs"]
12mod typing;
13
14use onestore::{
15 ExGuid, RevisionIndex, Store,
16 document::{Document, Kind},
17};
18use std::{collections::BTreeMap, fs, path::PathBuf};
19
20/// `source`, a table of contents, with the notebook recoloured to `color`.
21fn 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
30fn 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}