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.chunks_exact(2)
150 .map(|c| u16::from_le_bytes([c[0], c[1]]))
151 .collect::<Vec<_>>()
152 )
153 .contains("First.one")
154 );
155 assert!(notebook.rename("Kept/Inner.one", "First").is_ok());
156 assert!(notebook.delete("Missing.one").is_err());
157 placement(
158 &root,
159 &[
160 ("Renamed.one", 0x6108912a),
161 ("Kept", 0xf2a400f4),
162 ("Kept/First.one", 0x2e1b24f3),
163 ("OneNote_RecycleBin", 0x986b646a),
164 ("OneNote_RecycleBin/First.one", 0x2e1b24f3),
165 ],
166 );
167 if let Some(directory) = std::env::var_os("NOTEBOOK_STRUCTURE_EXPORT_DELETED") {
168 copy_dir(&root, std::path::Path::new(&directory));
169 }
170}
171
172fn copy_dir(from: &std::path::Path, to: &std::path::Path) {
173 std::fs::create_dir_all(to).unwrap();
174 for entry in std::fs::read_dir(from).unwrap() {
175 let entry = entry.unwrap();
176 let target = to.join(entry.file_name());
177 if entry.file_type().unwrap().is_dir() {
178 copy_dir(&entry.path(), &target);
179 } else {
180 std::fs::copy(entry.path(), target).unwrap();
181 }
182 }
183}
184
185#[test]
186fn a_rename_keeps_its_place_in_the_notebook_and_in_a_group() {
187 let temporary = tempfile::tempdir().unwrap();
188 let root = temporary.path().join("notebook");
189 let page = onestore::PageCreation::new(None, Some(""), "Author").unwrap();
190 let mut notebook =
191 Notebook::create(&root, temporary.path().join("cache"), 0x00d7ff, &page).unwrap();
192 notebook.create_group("", "Group").unwrap();
193 for folder in ["", "Group"] {
194 for name in ["Video", "Song", "Album"] {
195 notebook.create_section(folder, name, &page).unwrap();
196 }
197 }
198 let path = |folder: &str, name: &str| {
199 if folder.is_empty() {
200 format!("{name}.one")
201 } else {
202 format!("{folder}/{name}.one")
203 }
204 };
205 notebook
206 .reorder(
207 "",
208 &["Album.one", "Video.one", "New Section 1.one", "Song.one"],
209 )
210 .unwrap();
211 notebook
212 .reorder("Group", &["Group/Album.one", "Group/Video.one"])
213 .unwrap();
214 notebook.rename("Video.one", "Kitchen").unwrap();
215 notebook.rename("Group/Video.one", "Kitchen").unwrap();
216 notebook.rename("Group", "Renamed").unwrap();
217 assert_eq!(
218 names(notebook.catalog()),
219 (
220 ["Album", "Kitchen", "New Section 1", "Song"]
221 .map(|name| path("", name))
222 .to_vec(),
223 vec!["Renamed".to_owned()]
224 )
225 );
226 assert_eq!(
227 names(&notebook.catalog().groups[0]).0,
228 ["Album", "Kitchen", "Song"].map(|name| path("Renamed", name))
229 );
230}
231
232/// Each entry a TOC file lists: its filename and file identity.
233fn listed(path: &std::path::Path) -> Vec<(String, [u8; 16])> {
234 let image = onestore::read_file(path).unwrap();
235 let store = onestore::Store::parse(&image).unwrap();
236 let index = onestore::RevisionIndex::parse(&store).unwrap();
237 let document = onestore::document::Document::parse(&index).unwrap();
238 let revision = document.active(document.root).unwrap();
239 let onestore::document::Kind::Toc { entries, .. } = &revision.nodes[&revision.roots[&1]].kind
240 else {
241 panic!()
242 };
243 entries
244 .iter()
245 .map(|id| match &revision.nodes[id].kind {
246 onestore::document::Kind::Toc {
247 filename, identity, ..
248 } => (filename.clone().unwrap_or_default(), identity.unwrap()),
249 _ => panic!(),
250 })
251 .collect()
252}
253
254fn file_id(path: &std::path::Path) -> [u8; 16] {
255 onestore::Store::parse(&onestore::read_file(path).unwrap())
256 .unwrap()
257 .header
258 .file_id
259}
260
261#[test]
262fn a_page_delete_lists_the_recycle_bin_it_finds_or_makes_and_leaves_an_unreadable_one() {
263 let temporary = tempfile::tempdir().unwrap();
264 let root = temporary.path().join("notebook");
265 let bin = root.join("OneNote_RecycleBin");
266 let page = onestore::PageCreation::new(None, Some("Kept"), "Author").unwrap();
267 let mut notebook =
268 Notebook::create(&root, temporary.path().join("cache"), 0x00d7ff, &page).unwrap();
269 let recycle = |notebook: &mut Notebook| {
270 let image = notebook.read_section("New Section 1.one").unwrap();
271 let pages = notebook::session::stored_pages(&image).unwrap();
272 notebook.recycle_pages(&[pages[0].page.clone()], "Author")
273 };
274 let bins = |root: &std::path::Path| -> Vec<[u8; 16]> {
275 listed(&root.join("Open Notebook.onetoc2"))
276 .into_iter()
277 .filter(|(name, _)| name == "OneNote_RecycleBin")
278 .map(|(_, identity)| identity)
279 .collect()
280 };
281 recycle(&mut notebook).unwrap();
282
283 // Gone from the folder, the bin's entry is stale; the next bin takes its name.
284 std::fs::remove_dir_all(&bin).unwrap();
285 notebook.refresh().unwrap();
286 recycle(&mut notebook).unwrap();
287 let toc = bin.join("Open Notebook.onetoc2");
288 assert_eq!(bins(&root), [file_id(&toc)]);
289 let deleted = file_id(&bin.join("OneNote_DeletedPages.one"));
290 assert_eq!(listed(&toc), [("OneNote_DeletedPages.one".into(), deleted)]);
291
292 // A bin whose TOC has another name and misses the deleted pages is kept and completed.
293 std::fs::remove_file(&toc).unwrap();
294 let other = bin.join("OneNote Table Of Contents.onetoc2");
295 std::fs::write(
296 &other,
297 onestore::create_table_of_contents("OneNote Table Of Contents.onetoc2", &[]).unwrap(),
298 )
299 .unwrap();
300 let parent = file_id(&root.join("Open Notebook.onetoc2"));
301 onestore::place_file(&other, parent, "OneNote_RecycleBin").unwrap();
302 notebook.refresh().unwrap();
303 recycle(&mut notebook).unwrap();
304 assert!(!toc.exists());
305 assert_eq!(bins(&root), [file_id(&other)]);
306 assert_eq!(
307 listed(&other),
308 [("OneNote_DeletedPages.one".into(), deleted)]
309 );
310 let ancestor =
311 onestore::Store::parse(&onestore::read_file(bin.join("OneNote_DeletedPages.one")).unwrap())
312 .unwrap()
313 .header
314 .ancestor;
315 assert_eq!(ancestor, file_id(&other));
316 // `NOTEBOOK_RECYCLE_EXPORT` names a new directory receiving it for a cold reopen.
317 if let Some(directory) = std::env::var_os("NOTEBOOK_RECYCLE_EXPORT") {
318 copy_dir(&root, std::path::Path::new(&directory));
319 }
320 // A bin that cannot be read is neither replaced nor added to.
321 #[cfg(unix)]
322 {
323 use std::os::unix::fs::PermissionsExt;
324 // Permission bits do not bind the superuser.
325 if unsafe { libc::geteuid() } == 0 {
326 return;
327 }
328 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o000)).unwrap();
329 let denied = notebook.refresh().and_then(|_| recycle(&mut notebook));
330 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
331 assert!(denied.is_err());
332 let names: Vec<_> = std::fs::read_dir(&bin)
333 .unwrap()
334 .map(|entry| entry.unwrap().file_name().into_string().unwrap())
335 .collect();
336 assert_eq!(names.len(), 2, "{names:?}");
337 }
338}
339
340/// What the recycle bin holds goes for good once its pages last changed over 60 days
341/// before, as OneNote 2010 prunes it; a pruned section's TOC entry stays, as OneNote leaves it
342/// (`corpus/recycle-purge`). `NOTEBOOK_PURGE_EXPORT` names a new directory receiving the
343/// purged notebook for a cold reopen.
344#[test]
345fn the_recycle_bin_forgets_pages_and_sections_unchanged_for_sixty_days() {
346 let temporary = tempfile::tempdir().unwrap();
347 let root = temporary.path().join("notebook");
348 let bin = root.join("OneNote_RecycleBin");
349 let page = onestore::PageCreation::new(None, Some("Kept"), "Author").unwrap();
350 let mut notebook =
351 Notebook::create(&root, temporary.path().join("cache"), 0x00d7ff, &page).unwrap();
352 let old = onestore::PageCreation::new(None, Some("Old page"), "Author").unwrap();
353 notebook.create_section("", "Old", &old).unwrap();
354 let image = notebook.read_section("New Section 1.one").unwrap();
355 let pages = notebook::session::stored_pages(&image).unwrap();
356 notebook
357 .recycle_pages(&[pages[0].page.clone()], "Author")
358 .unwrap();
359 notebook.delete("Old.one").unwrap();
360 let toc = bin.join("Open Notebook.onetoc2");
361 let before = listed(&toc);
362 assert!(before.iter().any(|(name, _)| name == "Old.one"));
363 let deleted = bin.join("OneNote_DeletedPages.one");
364 let recycled = |notebook: &Notebook| {
365 let image = notebook
366 .read_section("OneNote_RecycleBin/OneNote_DeletedPages.one")
367 .unwrap();
368 notebook::session::stored_pages(&image).unwrap().len()
369 };
370 assert_eq!(recycled(&notebook), 1);
371 let now = std::time::SystemTime::now()
372 .duration_since(std::time::UNIX_EPOCH)
373 .unwrap()
374 .as_secs() as u32
375 - 315_532_800;
376 let day = 86_400;
377 assert_eq!(notebook.purge_recycle_bin(now + 59 * day).unwrap(), 0);
378 assert!(bin.join("Old.one").exists());
379 let length = std::fs::metadata(&deleted).unwrap().len();
380 assert_eq!(notebook.purge_recycle_bin(now + 61 * day).unwrap(), 2);
381 assert!(!bin.join("Old.one").exists());
382 assert_eq!(recycled(&notebook), 0);
383 // One appended revision; the bin's TOC untouched.
384 assert!(std::fs::metadata(&deleted).unwrap().len() > length);
385 assert_eq!(listed(&toc), before);
386 assert_eq!(notebook.purge_recycle_bin(now + 61 * day).unwrap(), 0);
387 if let Some(directory) = std::env::var_os("NOTEBOOK_PURGE_EXPORT") {
388 copy_dir(&root, std::path::Path::new(&directory));
389 }
390}