1use notebook::session::Notebook;
2use std::io::Write;
3
4/// Each file's header names its parent TOC and carries the name CRC OneNote wrote for the
5/// same name in `corpus/notebook-edit/{structured,deleted}/cold`.
6fn placement(root: &std::path::Path, expected: &[(&str, u32)]) {
7 let header = |path: std::path::PathBuf| {
8 onestore::Store::parse(&std::fs::read(path).unwrap())
9 .unwrap()
10 .header
11 };
12 for (path, crc) in expected {
13 let (folder, name) = path.rsplit_once('/').unwrap_or(("", path));
14 let parent = header(root.join(folder).join("Open Notebook.onetoc2")).file_id;
15 let file = if name.ends_with(".one") {
16 root.join(path)
17 } else {
18 root.join(path).join("Open Notebook.onetoc2")
19 };
20 let header = header(file);
21 assert_eq!((header.ancestor, header.name_crc), (parent, *crc), "{path}");
22 }
23}
24
25fn names(folder: &notebook::discover::Folder) -> (Vec<String>, Vec<String>) {
26 (
27 folder.sections.iter().map(|s| s.path.clone()).collect(),
28 folder.groups.iter().map(|g| g.path.clone()).collect(),
29 )
30}
31
32/// `NOTEBOOK_STRUCTURE_EXPORT` names a new directory receiving the final notebook for a
33/// cold reopen.
34#[test]
35fn sections_and_groups_are_created_renamed_coloured_ordered_and_deleted() {
36 let temporary = tempfile::tempdir().unwrap();
37 let root = temporary.path().join("notebook");
38 std::fs::create_dir(&root).unwrap();
39 let first = onestore::create_section("First.one", "First page", "Author").unwrap();
40 std::fs::File::create_new(root.join("First.one"))
41 .unwrap()
42 .write_all(&first)
43 .unwrap();
44 let first_id = onestore::Store::parse(&first).unwrap().header.file_id;
45 std::fs::write(
46 root.join("Open Notebook.onetoc2"),
47 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("First.one", first_id)])
48 .unwrap(),
49 )
50 .unwrap();
51 let toc = onestore::read_file(root.join("Open Notebook.onetoc2")).unwrap();
52 let toc_id = onestore::Store::parse(&toc).unwrap().header.file_id;
53 onestore::place_file(root.join("First.one"), toc_id, "First.one").unwrap();
54 let mut notebook = Notebook::open(&root, temporary.path().join("cache")).unwrap();
55 assert_eq!(
56 notebook
57 .create_section(
58 "",
59 "Second",
60 &onestore::PageCreation::new(None, Some(""), "Author").unwrap()
61 )
62 .unwrap(),
63 "Second.one"
64 );
65 assert_eq!(notebook.create_group("", "Archive").unwrap(), "Archive");
66 assert_eq!(
67 notebook
68 .create_section(
69 "Archive",
70 "Inner",
71 &onestore::PageCreation::new(None, Some(""), "Author").unwrap()
72 )
73 .unwrap(),
74 "Archive/Inner.one"
75 );
76 assert_eq!(
77 names(notebook.catalog()),
78 (
79 vec!["First.one".to_owned(), "Second.one".to_owned()],
80 vec!["Archive".to_owned()]
81 )
82 );
83 assert_eq!(
84 names(&notebook.catalog().groups[0]),
85 (vec!["Archive/Inner.one".to_owned()], vec![])
86 );
87 assert_eq!(
88 notebook.rename("Second.one", "Renamed").unwrap(),
89 "Renamed.one"
90 );
91 assert_eq!(notebook.rename("Archive", "Kept").unwrap(), "Kept");
92 notebook
93 .set_section_color("Renamed.one", Some(0x5ed7ff))
94 .unwrap();
95 notebook.reorder("", &["Kept", "Renamed.one"]).unwrap();
96 assert_eq!(
97 names(notebook.catalog()),
98 (
99 vec!["Renamed.one".to_owned(), "First.one".to_owned()],
100 vec!["Kept".to_owned()]
101 )
102 );
103 let toc = notebook.catalog().toc.as_ref().unwrap();
104 let orders: Vec<(Option<String>, u32)> = toc
105 .unresolved
106 .iter()
107 .map(|entry| (entry.filename.clone(), entry.order))
108 .collect();
109 assert!(orders.is_empty(), "{orders:?}");
110 let colour = onestore::read_file(root.join("Renamed.one")).unwrap();
111 let store = onestore::Store::parse(&colour).unwrap();
112 let index = onestore::RevisionIndex::parse(&store).unwrap();
113 let document = onestore::document::Document::parse(&index).unwrap();
114 let revision = document.active(document.root).unwrap();
115 let onestore::document::Kind::SectionMetadata { color, .. } =
116 &revision.nodes[&revision.roots[&2]].kind
117 else {
118 panic!()
119 };
120 assert_eq!(*color, Some(0x5ed7ff));
121 placement(
122 &root,
123 &[
124 ("First.one", 0x2e1b24f3),
125 ("Renamed.one", 0x6108912a),
126 ("Kept", 0xf2a400f4),
127 ("Kept/Inner.one", 0x83e261da),
128 ],
129 );
130 if let Some(directory) = std::env::var_os("NOTEBOOK_STRUCTURE_EXPORT") {
131 copy_dir(&root, std::path::Path::new(&directory));
132 }
133 notebook.delete("First.one").unwrap();
134 assert_eq!(
135 names(notebook.catalog()),
136 (
137 vec!["Renamed.one".to_owned()],
138 vec!["Kept".to_owned(), "OneNote_RecycleBin".to_owned()]
139 )
140 );
141 assert_eq!(
142 names(&notebook.catalog().groups[1]),
143 (vec!["OneNote_RecycleBin/First.one".to_owned()], vec![])
144 );
145 assert!(root.join("OneNote_RecycleBin/First.one").exists());
146 let bin = onestore::read_file(root.join("OneNote_RecycleBin/Open Notebook.onetoc2")).unwrap();
147 assert!(
148 String::from_utf16_lossy(
149 &bin.as_chunks::<2>()
150 .0
151 .iter()
152 .map(|c| u16::from_le_bytes([c[0], c[1]]))
153 .collect::<Vec<_>>()
154 )
155 .contains("First.one")
156 );
157 assert!(notebook.rename("Kept/Inner.one", "First").is_ok());
158 assert!(notebook.delete("Missing.one").is_err());
159 placement(
160 &root,
161 &[
162 ("Renamed.one", 0x6108912a),
163 ("Kept", 0xf2a400f4),
164 ("Kept/First.one", 0x2e1b24f3),
165 ("OneNote_RecycleBin", 0x986b646a),
166 ("OneNote_RecycleBin/First.one", 0x2e1b24f3),
167 ],
168 );
169 if let Some(directory) = std::env::var_os("NOTEBOOK_STRUCTURE_EXPORT_DELETED") {
170 copy_dir(&root, std::path::Path::new(&directory));
171 }
172}
173
174fn copy_dir(from: &std::path::Path, to: &std::path::Path) {
175 std::fs::create_dir_all(to).unwrap();
176 for entry in std::fs::read_dir(from).unwrap() {
177 let entry = entry.unwrap();
178 let target = to.join(entry.file_name());
179 if entry.file_type().unwrap().is_dir() {
180 copy_dir(&entry.path(), &target);
181 } else {
182 std::fs::copy(entry.path(), target).unwrap();
183 }
184 }
185}
186
187#[test]
188fn a_rename_keeps_its_place_in_the_notebook_and_in_a_group() {
189 let temporary = tempfile::tempdir().unwrap();
190 let root = temporary.path().join("notebook");
191 let page = onestore::PageCreation::new(None, Some(""), "Author").unwrap();
192 let mut notebook =
193 Notebook::create(&root, temporary.path().join("cache"), 0x00d7ff, &page).unwrap();
194 notebook.create_group("", "Group").unwrap();
195 for folder in ["", "Group"] {
196 for name in ["Video", "Song", "Album"] {
197 notebook.create_section(folder, name, &page).unwrap();
198 }
199 }
200 let path = |folder: &str, name: &str| {
201 if folder.is_empty() {
202 format!("{name}.one")
203 } else {
204 format!("{folder}/{name}.one")
205 }
206 };
207 notebook
208 .reorder(
209 "",
210 &["Album.one", "Video.one", "New Section 1.one", "Song.one"],
211 )
212 .unwrap();
213 notebook
214 .reorder("Group", &["Group/Album.one", "Group/Video.one"])
215 .unwrap();
216 notebook.rename("Video.one", "Kitchen").unwrap();
217 notebook.rename("Group/Video.one", "Kitchen").unwrap();
218 notebook.rename("Group", "Renamed").unwrap();
219 assert_eq!(
220 names(notebook.catalog()),
221 (
222 ["Album", "Kitchen", "New Section 1", "Song"]
223 .map(|name| path("", name))
224 .to_vec(),
225 vec!["Renamed".to_owned()]
226 )
227 );
228 assert_eq!(
229 names(&notebook.catalog().groups[0]).0,
230 ["Album", "Kitchen", "Song"].map(|name| path("Renamed", name))
231 );
232}
233
234/// Each entry a TOC file lists: its filename and file identity.
235fn listed(path: &std::path::Path) -> Vec<(String, [u8; 16])> {
236 let image = onestore::read_file(path).unwrap();
237 let store = onestore::Store::parse(&image).unwrap();
238 let index = onestore::RevisionIndex::parse(&store).unwrap();
239 let document = onestore::document::Document::parse(&index).unwrap();
240 let revision = document.active(document.root).unwrap();
241 let onestore::document::Kind::Toc { entries, .. } = &revision.nodes[&revision.roots[&1]].kind
242 else {
243 panic!()
244 };
245 entries
246 .iter()
247 .map(|id| match &revision.nodes[id].kind {
248 onestore::document::Kind::Toc {
249 filename, identity, ..
250 } => (filename.clone().unwrap_or_default(), identity.unwrap()),
251 _ => panic!(),
252 })
253 .collect()
254}
255
256fn file_id(path: &std::path::Path) -> [u8; 16] {
257 onestore::Store::parse(&onestore::read_file(path).unwrap())
258 .unwrap()
259 .header
260 .file_id
261}
262
263#[test]
264fn a_page_delete_lists_the_recycle_bin_it_finds_or_makes_and_leaves_an_unreadable_one() {
265 let temporary = tempfile::tempdir().unwrap();
266 let root = temporary.path().join("notebook");
267 let bin = root.join("OneNote_RecycleBin");
268 let page = onestore::PageCreation::new(None, Some("Kept"), "Author").unwrap();
269 let mut notebook =
270 Notebook::create(&root, temporary.path().join("cache"), 0x00d7ff, &page).unwrap();
271 let recycle = |notebook: &mut Notebook| {
272 let image = notebook.read_section("New Section 1.one").unwrap();
273 let pages = notebook::session::stored_pages(&image).unwrap();
274 notebook.recycle_pages(&[pages[0].page.clone()], "Author")
275 };
276 let bins = |root: &std::path::Path| -> Vec<[u8; 16]> {
277 listed(&root.join("Open Notebook.onetoc2"))
278 .into_iter()
279 .filter(|(name, _)| name == "OneNote_RecycleBin")
280 .map(|(_, identity)| identity)
281 .collect()
282 };
283 recycle(&mut notebook).unwrap();
284
285 // Gone from the folder, the bin's entry is stale; the next bin takes its name.
286 std::fs::remove_dir_all(&bin).unwrap();
287 notebook.refresh().unwrap();
288 recycle(&mut notebook).unwrap();
289 let toc = bin.join("Open Notebook.onetoc2");
290 assert_eq!(bins(&root), [file_id(&toc)]);
291 let deleted = file_id(&bin.join("OneNote_DeletedPages.one"));
292 assert_eq!(listed(&toc), [("OneNote_DeletedPages.one".into(), deleted)]);
293
294 // A bin whose TOC has another name and misses the deleted pages is kept and completed.
295 std::fs::remove_file(&toc).unwrap();
296 let other = bin.join("OneNote Table Of Contents.onetoc2");
297 std::fs::write(
298 &other,
299 onestore::create_table_of_contents("OneNote Table Of Contents.onetoc2", &[]).unwrap(),
300 )
301 .unwrap();
302 let parent = file_id(&root.join("Open Notebook.onetoc2"));
303 onestore::place_file(&other, parent, "OneNote_RecycleBin").unwrap();
304 notebook.refresh().unwrap();
305 recycle(&mut notebook).unwrap();
306 assert!(!toc.exists());
307 assert_eq!(bins(&root), [file_id(&other)]);
308 assert_eq!(
309 listed(&other),
310 [("OneNote_DeletedPages.one".into(), deleted)]
311 );
312 let ancestor =
313 onestore::Store::parse(&onestore::read_file(bin.join("OneNote_DeletedPages.one")).unwrap())
314 .unwrap()
315 .header
316 .ancestor;
317 assert_eq!(ancestor, file_id(&other));
318 // `NOTEBOOK_RECYCLE_EXPORT` names a new directory receiving it for a cold reopen.
319 if let Some(directory) = std::env::var_os("NOTEBOOK_RECYCLE_EXPORT") {
320 copy_dir(&root, std::path::Path::new(&directory));
321 }
322 // A bin that cannot be read is neither replaced nor added to.
323 #[cfg(unix)]
324 {
325 use std::os::unix::fs::PermissionsExt;
326 // Permission bits do not bind the superuser.
327 if unsafe { libc::geteuid() } == 0 {
328 return;
329 }
330 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o000)).unwrap();
331 let denied = notebook.refresh().and_then(|_| recycle(&mut notebook));
332 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
333 assert!(denied.is_err());
334 let names: Vec<_> = std::fs::read_dir(&bin)
335 .unwrap()
336 .map(|entry| entry.unwrap().file_name().into_string().unwrap())
337 .collect();
338 assert_eq!(names.len(), 2, "{names:?}");
339 }
340}
341
342/// What the recycle bin holds goes for good once its pages last changed over 60 days
343/// before, as OneNote 2010 prunes it; a pruned section's TOC entry stays, as OneNote leaves it
344/// (`corpus/recycle-purge`). `NOTEBOOK_PURGE_EXPORT` names a new directory receiving the
345/// purged notebook for a cold reopen.
346#[test]
347fn the_recycle_bin_forgets_pages_and_sections_unchanged_for_sixty_days() {
348 let temporary = tempfile::tempdir().unwrap();
349 let root = temporary.path().join("notebook");
350 let bin = root.join("OneNote_RecycleBin");
351 let page = onestore::PageCreation::new(None, Some("Kept"), "Author").unwrap();
352 let mut notebook =
353 Notebook::create(&root, temporary.path().join("cache"), 0x00d7ff, &page).unwrap();
354 let old = onestore::PageCreation::new(None, Some("Old page"), "Author").unwrap();
355 notebook.create_section("", "Old", &old).unwrap();
356 let image = notebook.read_section("New Section 1.one").unwrap();
357 let pages = notebook::session::stored_pages(&image).unwrap();
358 notebook
359 .recycle_pages(&[pages[0].page.clone()], "Author")
360 .unwrap();
361 notebook.delete("Old.one").unwrap();
362 let toc = bin.join("Open Notebook.onetoc2");
363 let before = listed(&toc);
364 assert!(before.iter().any(|(name, _)| name == "Old.one"));
365 let deleted = bin.join("OneNote_DeletedPages.one");
366 let recycled = |notebook: &Notebook| {
367 let image = notebook
368 .read_section("OneNote_RecycleBin/OneNote_DeletedPages.one")
369 .unwrap();
370 notebook::session::stored_pages(&image).unwrap().len()
371 };
372 assert_eq!(recycled(&notebook), 1);
373 let now = std::time::SystemTime::now()
374 .duration_since(std::time::UNIX_EPOCH)
375 .unwrap()
376 .as_secs() as u32
377 - 315_532_800;
378 let day = 86_400;
379 assert_eq!(notebook.purge_recycle_bin(now + 59 * day).unwrap(), 0);
380 assert!(bin.join("Old.one").exists());
381 let length = std::fs::metadata(&deleted).unwrap().len();
382 assert_eq!(notebook.purge_recycle_bin(now + 61 * day).unwrap(), 2);
383 assert!(!bin.join("Old.one").exists());
384 assert_eq!(recycled(&notebook), 0);
385 // One appended revision; the bin's TOC untouched.
386 assert!(std::fs::metadata(&deleted).unwrap().len() > length);
387 assert_eq!(listed(&toc), before);
388 assert_eq!(notebook.purge_recycle_bin(now + 61 * day).unwrap(), 0);
389 if let Some(directory) = std::env::var_os("NOTEBOOK_PURGE_EXPORT") {
390 copy_dir(&root, std::path::Path::new(&directory));
391 }
392}