1use notebook::discover::{Cache, Entry, Error, Limits, Local, Reason, Source, discover};
2use std::{fs, io, path::Path};
3
4fn limits() -> Limits {
5 Limits {
6 entries: 1000,
7 bytes_per_file: 16 * 1024 * 1024,
8 depth: 16,
9 }
10}
11
12fn fixture(root: &Path) -> Vec<u8> {
13 let section = onestore::create_section("one.one", "Retained 🦀", "Fixture").unwrap();
14 fs::write(root.join("one.one"), &section).unwrap();
15 let identity = onestore::Store::parse(&section).unwrap().header.file_id;
16 let toc = onestore::create_table_of_contents("Open Notebook.onetoc2", &[("one.one", identity)])
17 .unwrap();
18 fs::write(root.join("Open Notebook.onetoc2"), toc).unwrap();
19 section
20}
21
22#[test]
23fn rename_preserves_identity_and_does_not_trust_a_stale_cached_filename() {
24 let root = tempfile::tempdir().unwrap();
25 let section = fixture(root.path());
26 let mut source = Local::open(root.path()).unwrap();
27 let before = discover(&mut source, limits()).unwrap();
28 fs::rename(
29 root.path().join("one.one"),
30 root.path().join("Renamed 🦀.ONE"),
31 )
32 .unwrap();
33 let after = discover(&mut source, limits()).unwrap();
34 assert_eq!(before.sections[0].file_id, after.sections[0].file_id);
35 assert_eq!(after.sections[0].path, "Renamed 🦀.ONE");
36 assert!(after.toc.as_ref().unwrap().unresolved.is_empty());
37 assert_eq!(
38 fs::read(root.path().join("Renamed 🦀.ONE")).unwrap(),
39 section
40 );
41 fs::write(
42 root.path().join("one.one"),
43 onestore::create_section("one.one", "Different", "Fixture").unwrap(),
44 )
45 .unwrap();
46 let with_replacement = discover(&mut source, limits()).unwrap();
47 assert_eq!(
48 with_replacement.sections[0].file_id,
49 before.sections[0].file_id
50 );
51 assert_eq!(with_replacement.sections[0].path, "Renamed 🦀.ONE");
52 assert_ne!(
53 with_replacement.sections[1].file_id,
54 before.sections[0].file_id
55 );
56 fs::remove_file(root.path().join("Renamed 🦀.ONE")).unwrap();
57 let absent = discover(&mut source, limits()).unwrap();
58 assert_eq!(absent.toc.as_ref().unwrap().unresolved.len(), 1);
59 assert_eq!(
60 absent.toc.as_ref().unwrap().unresolved[0].file,
61 before.sections[0].file_id
62 );
63 assert_ne!(
64 absent.sections[0].file_id,
65 absent.toc.as_ref().unwrap().unresolved[0].file
66 );
67}
68
69/// Sets `path`'s modification time to `seconds` after the epoch.
70fn touch(path: &Path, seconds: u64) {
71 fs::File::options()
72 .write(true)
73 .open(path)
74 .unwrap()
75 .set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(seconds))
76 .unwrap();
77}
78
79fn copy_of(unavailable: &notebook::discover::Unavailable) -> Option<&str> {
80 match &unavailable.reason {
81 Reason::Copy { of } => Some(of),
82 _ => None,
83 }
84}
85
86/// Which of the folder's sections are copies of another, by path.
87fn copies(folder: &notebook::discover::Folder) -> Vec<(&str, bool)> {
88 folder
89 .sections
90 .iter()
91 .map(|section| (section.path.as_str(), section.copy))
92 .collect()
93}
94
95/// OneNote 2010 opens a copied section file beside its original as a section of its own
96/// (`Cross 2.one`, 2026-09-29 lab run); the one the TOC names is the original, however
97/// short the copy's path.
98#[test]
99fn a_copied_section_lists_beside_the_one_the_toc_names() {
100 let root = tempfile::tempdir().unwrap();
101 fixture(root.path());
102 fs::create_dir(root.path().join("group")).unwrap();
103 for copy in ["o.one", "group/other.one"] {
104 fs::copy(root.path().join("one.one"), root.path().join(copy)).unwrap();
105 touch(&root.path().join(copy), 2_000_000_000);
106 }
107 let catalog = discover(&mut Local::open(root.path()).unwrap(), limits()).unwrap();
108 assert!(catalog.unavailable.is_empty());
109 let mut listed = copies(&catalog);
110 listed.sort();
111 assert_eq!(listed, [("o.one", true), ("one.one", false)]);
112 assert_eq!(copies(&catalog.groups[0]), [("group/other.one", true)]);
113}
114
115/// Without a TOC, the original is the copy with the shortest path, however new the others:
116/// a choice that stands while the files keep their names.
117#[test]
118fn without_a_toc_the_shortest_path_is_the_original() {
119 let root = tempfile::tempdir().unwrap();
120 fixture(root.path());
121 fs::remove_file(root.path().join("Open Notebook.onetoc2")).unwrap();
122 fs::copy(root.path().join("one.one"), root.path().join("one 2.one")).unwrap();
123 touch(&root.path().join("one.one"), 1_000_000_000);
124 touch(&root.path().join("one 2.one"), 2_000_000_000);
125 let catalog = discover(&mut Local::open(root.path()).unwrap(), limits()).unwrap();
126 let mut listed = copies(&catalog);
127 listed.sort();
128 assert_eq!(listed, [("one 2.one", true), ("one.one", false)]);
129}
130
131#[test]
132fn a_copied_group_lists_once_with_its_sections() {
133 let root = tempfile::tempdir().unwrap();
134 fixture(root.path());
135 let group = root.path().join("Group");
136 fs::create_dir(&group).unwrap();
137 let inner = onestore::create_section("Inner.one", "Inner", "Fixture").unwrap();
138 fs::write(group.join("Inner.one"), &inner).unwrap();
139 let identity = onestore::Store::parse(&inner).unwrap().header.file_id;
140 fs::write(
141 group.join("Open Notebook.onetoc2"),
142 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("Inner.one", identity)])
143 .unwrap(),
144 )
145 .unwrap();
146 let copy = root.path().join("Group 2");
147 fs::create_dir(&copy).unwrap();
148 for name in ["Inner.one", "Open Notebook.onetoc2"] {
149 fs::copy(group.join(name), copy.join(name)).unwrap();
150 touch(&group.join(name), 1_000_000_000);
151 touch(&copy.join(name), 2_000_000_000);
152 }
153 let catalog = discover(&mut Local::open(root.path()).unwrap(), limits()).unwrap();
154 assert_eq!(catalog.groups.len(), 1);
155 assert_eq!(catalog.groups[0].path, "Group 2");
156 assert_eq!(catalog.groups[0].sections[0].path, "Group 2/Inner.one");
157 assert_eq!(catalog.unavailable.len(), 1);
158 assert_eq!(catalog.unavailable[0].path, "Group");
159 assert!(catalog.unavailable[0].group);
160 assert_eq!(copy_of(&catalog.unavailable[0]), Some("Group 2"));
161}
162
163/// iOS lists a file iCloud Drive evicted as `.Name.icloud`: the section lists as not
164/// downloaded under its own name, and nothing writes a TOC over an evicted one.
165#[test]
166fn evicted_files_list_under_their_names_until_downloaded() {
167 let temporary = tempfile::tempdir().unwrap();
168 let root = temporary.path().join("notebook");
169 fs::create_dir(&root).unwrap();
170 let section = fixture(&root);
171 fs::rename(root.join("one.one"), root.join(".one.one.icloud")).unwrap();
172 let mut source = Local::open(&root).unwrap();
173 let catalog = discover(&mut source, limits()).unwrap();
174 assert!(catalog.sections.is_empty());
175 assert_eq!(catalog.unavailable.len(), 1);
176 assert_eq!(catalog.unavailable[0].path, "one.one");
177 assert_eq!(catalog.unavailable[0].reason, Reason::Evicted);
178 // Downloading can leave the placeholder beside the file for a moment.
179 fs::write(root.join("one.one"), &section).unwrap();
180 let catalog = discover(&mut source, limits()).unwrap();
181 assert_eq!(catalog.sections[0].path, "one.one");
182 assert!(catalog.unavailable.is_empty());
183
184 fs::remove_file(root.join(".one.one.icloud")).unwrap();
185 fs::rename(
186 root.join("Open Notebook.onetoc2"),
187 root.join(".Open Notebook.onetoc2.icloud"),
188 )
189 .unwrap();
190 let catalog = discover(&mut source, limits()).unwrap();
191 assert!(catalog.toc.is_none());
192 assert_eq!(catalog.sections[0].path, "one.one");
193 let mut notebook =
194 notebook::session::Notebook::open(&root, temporary.path().join("cache")).unwrap();
195 let page = onestore::PageCreation::new(None, Some(""), "Author").unwrap();
196 assert!(notebook.create_section("", "Second", &page).is_err());
197 assert!(!root.join("Open Notebook.onetoc2").exists());
198}
199
200#[test]
201fn cached_sections_and_tocs_survive_eviction_to_different_placeholder_metadata() {
202 let root = tempfile::tempdir().unwrap();
203 fixture(root.path());
204 let mut cache = Cache::default();
205 let mut source = Local::open(root.path()).unwrap();
206 let before = cache.discover(&mut source, limits()).unwrap();
207 for name in ["one.one", "Open Notebook.onetoc2"] {
208 fs::remove_file(root.path().join(name)).unwrap();
209 fs::write(root.path().join(format!(".{name}.icloud")), b"placeholder").unwrap();
210 }
211 for _ in 0..2 {
212 let evicted = cache.discover(&mut source, limits()).unwrap();
213 assert_eq!(evicted.sections[0].file_id, before.sections[0].file_id);
214 assert_eq!(evicted.order, before.order);
215 assert!(evicted.toc.is_some() && evicted.unavailable.is_empty());
216 }
217 fs::write(
218 root.path().join("one.one"),
219 onestore::create_section("one.one", "Downloaded change", "Fixture").unwrap(),
220 )
221 .unwrap();
222 touch(&root.path().join("one.one"), 1_000_000_000);
223 let downloaded = cache.discover(&mut source, limits()).unwrap();
224 assert_ne!(downloaded.sections[0].file_id, before.sections[0].file_id);
225}
226
227/// A source whose files list as macOS lists those iCloud Drive evicted: dataless, under their
228/// names and listings, and never read.
229struct Dataless(Local);
230
231impl Source for Dataless {
232 fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>> {
233 let mut entries = self.0.entries(path, limit)?;
234 for entry in &mut entries {
235 entry.kind = notebook::discover::EntryKind::Evicted;
236 }
237 Ok(entries)
238 }
239
240 fn read(&mut self, path: &str, _: usize) -> io::Result<Vec<u8>> {
241 panic!("read {path}, which is not on this device");
242 }
243}
244
245/// A dataless file lists as its last read found it, and waits for download without a cache.
246#[test]
247fn dataless_files_list_as_last_read_or_wait_for_their_download() {
248 let root = tempfile::tempdir().unwrap();
249 fixture(root.path());
250 let mut cache = Cache::default();
251 cache
252 .discover(&mut Local::open(root.path()).unwrap(), limits())
253 .unwrap();
254 let catalog = cache
255 .discover(&mut Dataless(Local::open(root.path()).unwrap()), limits())
256 .unwrap();
257 assert_eq!(catalog.sections[0].path, "one.one");
258 assert!(catalog.toc.is_some() && catalog.unavailable.is_empty());
259
260 let catalog = discover(&mut Dataless(Local::open(root.path()).unwrap()), limits()).unwrap();
261 assert!(catalog.sections.is_empty() && catalog.toc.is_none());
262 assert_eq!(catalog.unavailable[0].path, "one.one");
263 assert_eq!(catalog.unavailable[0].reason, Reason::Evicted);
264}
265
266#[test]
267fn locked_and_unreadable_sections_retain_their_storage_identity() {
268 let root = tempfile::tempdir().unwrap();
269 for (name, fixture) in [
270 (
271 "locked.one",
272 "native-encrypted/encrypted-01/notebook/synthetic.one",
273 ),
274 (
275 "stub.one",
276 "native-encrypted/cold-encrypted-02/notebook/Open Notebook.one",
277 ),
278 ] {
279 fs::copy(
280 Path::new("../../corpus").join(fixture),
281 root.path().join(name),
282 )
283 .unwrap();
284 }
285 let catalog = discover(&mut Local::open(root.path()).unwrap(), limits()).unwrap();
286 assert!(catalog.toc.is_none());
287 assert!(matches!(
288 catalog.sections[0].state,
289 notebook::discover::SectionState::Locked
290 ));
291 assert!(matches!(
292 catalog.sections[1].state,
293 notebook::discover::SectionState::Unreadable(_)
294 ));
295 for section in catalog.sections {
296 let bytes = fs::read(root.path().join(section.path)).unwrap();
297 assert_eq!(
298 section.file_id,
299 onestore::Store::parse(&bytes).unwrap().header.file_id
300 );
301 }
302}
303
304#[test]
305fn a_group_rename_retains_toc_identity_and_parent_order() {
306 let root = tempfile::tempdir().unwrap();
307 let native = Path::new("../../corpus/m6/native-features-01/notebook");
308 for relative in [
309 "Open Notebook.onetoc2",
310 "Group A/Open Notebook.onetoc2",
311 "Group A/Duplicate.one",
312 "Group B/Open Notebook.onetoc2",
313 "Group B/Nested/Open Notebook.onetoc2",
314 "Group B/Nested/Duplicate.one",
315 ] {
316 let target = root.path().join(relative);
317 fs::create_dir_all(target.parent().unwrap()).unwrap();
318 fs::copy(native.join(relative), target).unwrap();
319 }
320 let before = discover(&mut Local::open(root.path()).unwrap(), limits()).unwrap();
321 assert_eq!(before.groups[0].path, "Group A");
322 fs::rename(root.path().join("Group A"), root.path().join("Renamed 🦀")).unwrap();
323 let catalog = discover(&mut Local::open(root.path()).unwrap(), limits()).unwrap();
324 assert_eq!(catalog.groups[0].path, "Renamed 🦀");
325 assert_eq!(
326 catalog.groups[0].toc.as_ref().unwrap().file_id,
327 before.groups[0].toc.as_ref().unwrap().file_id
328 );
329 assert_eq!(catalog.groups[1].path, "Group B");
330 assert_eq!(
331 catalog.groups[0].sections[0].path,
332 "Renamed 🦀/Duplicate.one"
333 );
334 assert_eq!(
335 serde_json::to_value(&catalog.toc.unwrap().unresolved).unwrap(),
336 serde_json::to_value(&before.toc.unwrap().unresolved).unwrap()
337 );
338}
339
340#[test]
341fn limits_and_incomplete_files_do_not_produce_a_catalog() {
342 let root = tempfile::tempdir().unwrap();
343 fixture(root.path());
344 let mut source = Local::open(root.path()).unwrap();
345 assert!(
346 discover(
347 &mut source,
348 Limits {
349 entries: 1,
350 ..limits()
351 }
352 )
353 .is_err()
354 );
355 assert!(
356 discover(
357 &mut source,
358 Limits {
359 bytes_per_file: 8,
360 ..limits()
361 }
362 )
363 .is_err()
364 );
365 fs::create_dir(root.path().join("group")).unwrap();
366 assert!(matches!(
367 discover(
368 &mut source,
369 Limits {
370 depth: 0,
371 ..limits()
372 }
373 ),
374 Err(Error::Limit { .. })
375 ));
376 fs::write(root.path().join("Open Notebook.onetoc2"), b"unfinished").unwrap();
377 assert!(
378 matches!(discover(&mut source, limits()), Err(Error::Document { path, .. }) if path == "Open Notebook.onetoc2")
379 );
380}
381
382#[test]
383fn a_section_that_cannot_be_read_lists_as_unavailable_and_the_rest_open() {
384 /// Answers as a share does a file mid-commit, as many times as `busy` counts for its
385 /// path, and fails the path `failure` names.
386 struct Busy {
387 source: Local,
388 busy: Vec<(&'static str, usize)>,
389 failure: Option<(&'static str, io::ErrorKind)>,
390 }
391 impl Source for Busy {
392 fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>> {
393 self.source.entries(path, limit)
394 }
395 fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
396 if let Some((_, kind)) = self.failure.filter(|(failing, _)| *failing == path) {
397 return Err(kind.into());
398 }
399 if let Some((_, left)) = self.busy.iter_mut().find(|(busy, _)| *busy == path)
400 && *left > 0
401 {
402 *left -= 1;
403 return Err(io::ErrorKind::WouldBlock.into());
404 }
405 self.source.read(path, limit)
406 }
407 }
408 let root = tempfile::tempdir().unwrap();
409 fixture(root.path());
410 for name in ["briefly.one", "stuck.one"] {
411 fs::write(
412 root.path().join(name),
413 onestore::create_section(name, "Text", "Fixture").unwrap(),
414 )
415 .unwrap();
416 }
417 fs::write(root.path().join("broken.one"), b"unfinished").unwrap();
418 let mut source = Busy {
419 source: Local::open(root.path()).unwrap(),
420 busy: vec![("briefly.one", 2), ("stuck.one", usize::MAX)],
421 failure: Some(("one.one", io::ErrorKind::InvalidData)),
422 };
423 let catalog = discover(&mut source, limits()).unwrap();
424 let sections: Vec<_> = catalog
425 .sections
426 .iter()
427 .map(|section| &section.path)
428 .collect();
429 assert_eq!(sections, ["briefly.one"]);
430 let unavailable: Vec<_> = catalog
431 .unavailable
432 .iter()
433 .map(|entry| (entry.path.as_str(), &entry.reason))
434 .collect();
435 assert_eq!(
436 unavailable,
437 [
438 ("broken.one", &Reason::Unreadable),
439 ("one.one", &Reason::Unreadable),
440 ("stuck.one", &Reason::InUse),
441 ]
442 );
443
444 // A lost connection still fails the whole discovery.
445 source.failure = Some(("one.one", io::ErrorKind::ConnectionAborted));
446 assert!(matches!(
447 discover(&mut source, limits()),
448 Err(Error::Io { path, error }) if path == "one.one" && error.kind() == io::ErrorKind::ConnectionAborted
449 ));
450}
451
452#[test]
453fn a_failed_refresh_retains_the_previous_catalog_as_an_independent_value() {
454 struct Changing {
455 source: Local,
456 reads: usize,
457 fail: bool,
458 }
459 impl Source for Changing {
460 fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>> {
461 self.reads += 1;
462 let mut entries = self.source.entries(path, limit)?;
463 if self.reads == 2 {
464 if self.fail {
465 return Err(io::ErrorKind::ConnectionAborted.into());
466 }
467 entries[0].name.push_str(".renamed");
468 }
469 Ok(entries)
470 }
471 fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
472 self.source.read(path, limit)
473 }
474 }
475 let root = tempfile::tempdir().unwrap();
476 fixture(root.path());
477 let mut source = Local::open(root.path()).unwrap();
478 let catalog = discover(&mut source, limits()).unwrap();
479 let before = serde_json::to_value(&catalog).unwrap();
480 for fail in [false, true] {
481 let mut changing = Changing {
482 source: Local::open(root.path()).unwrap(),
483 reads: 0,
484 fail,
485 };
486 let error = discover(&mut changing, limits()).unwrap_err();
487 if fail {
488 assert!(
489 matches!(error, Error::Io { error, .. } if error.kind() == io::ErrorKind::ConnectionAborted)
490 );
491 } else {
492 assert!(matches!(error, Error::Changed { .. }));
493 }
494 assert_eq!(serde_json::to_value(&catalog).unwrap(), before);
495 }
496}
497
498#[test]
499fn local_access_stays_inside_the_selected_root() {
500 let root = tempfile::tempdir().unwrap();
501 fixture(root.path());
502 let mut source = Local::open(root.path()).unwrap();
503 for path in [
504 "../one.one",
505 "/one.one",
506 "x/../one.one",
507 "one.one\0",
508 "x\\one.one",
509 ] {
510 assert_eq!(
511 source.read(path, 100).unwrap_err().kind(),
512 io::ErrorKind::InvalidInput
513 );
514 }
515 #[cfg(unix)]
516 {
517 let other = tempfile::tempdir().unwrap();
518 fs::write(other.path().join("other.one"), b"outside").unwrap();
519 std::os::unix::fs::symlink(other.path().join("other.one"), root.path().join("link.one"))
520 .unwrap();
521 assert_eq!(
522 source.read("link.one", 100).unwrap_err().kind(),
523 io::ErrorKind::PermissionDenied
524 );
525 assert!(
526 matches!(discover(&mut source, limits()), Err(Error::Entry { path }) if path == "link.one")
527 );
528 }
529}
530
531/// Restores permissions a test took away, so the temporary directory can be removed.
532#[cfg(unix)]
533struct Restore(Vec<(std::path::PathBuf, u32)>);
534
535#[cfg(unix)]
536impl Drop for Restore {
537 fn drop(&mut self) {
538 use std::os::unix::fs::PermissionsExt;
539 for (path, mode) in &self.0 {
540 let _ = fs::set_permissions(path, fs::Permissions::from_mode(*mode));
541 }
542 }
543}
544
545#[cfg(unix)]
546#[test]
547fn unreadable_children_are_listed_as_unavailable_and_retried() {
548 use std::os::unix::fs::PermissionsExt;
549 // Permission bits do not bind the superuser.
550 if unsafe { libc::geteuid() } == 0 {
551 return;
552 }
553 let directory = tempfile::tempdir().unwrap();
554 let root = directory.path().join("notebook");
555 fs::create_dir(&root).unwrap();
556 fixture(&root);
557 fs::create_dir(root.join("Art")).unwrap();
558 fs::create_dir(root.join("Group")).unwrap();
559 for name in ["kept.one", "denied.one"] {
560 fs::write(
561 root.join("Group").join(name),
562 onestore::create_section(name, "Text", "Fixture").unwrap(),
563 )
564 .unwrap();
565 }
566 fs::write(
567 root.join("Group/Open Notebook.onetoc2"),
568 onestore::create_table_of_contents("Open Notebook.onetoc2", &[]).unwrap(),
569 )
570 .unwrap();
571 let _restore = Restore(vec![
572 (root.join("Art"), 0o755),
573 (root.join("Group/denied.one"), 0o644),
574 ]);
575 for path in [root.join("Art"), root.join("Group/denied.one")] {
576 fs::set_permissions(path, fs::Permissions::from_mode(0o000)).unwrap();
577 }
578
579 let mut notebook =
580 notebook::session::Notebook::open(&root, directory.path().join("cache")).unwrap();
581 let catalog = notebook.catalog();
582 assert_eq!(catalog.sections.len(), 1);
583 assert_eq!(catalog.groups.len(), 1);
584 assert_eq!(catalog.unavailable.len(), 1);
585 assert_eq!(catalog.unavailable[0].path, "Art");
586 assert!(catalog.unavailable[0].group);
587 assert!(catalog.unavailable[0].error.contains("Permission denied"));
588 let group = &catalog.groups[0];
589 assert_eq!(group.sections.len(), 1);
590 assert_eq!(group.sections[0].path, "Group/kept.one");
591 assert_eq!(group.unavailable[0].path, "Group/denied.one");
592 assert!(!group.unavailable[0].group);
593
594 // An unavailable entry is not the catalog's to change, nor a group holding one.
595 assert!(notebook.delete("Art").is_err());
596 assert!(notebook.delete("Group").is_err());
597 assert!(root.join("Group/kept.one").exists());
598 assert!(notebook.rename("Art", "Other").is_err());
599 assert!(notebook.create_group("", "Art").is_err());
600
601 fs::set_permissions(root.join("Art"), fs::Permissions::from_mode(0o755)).unwrap();
602 notebook.refresh().unwrap();
603 assert!(notebook.catalog().unavailable.is_empty());
604 assert_eq!(notebook.catalog().groups.len(), 2);
605
606 // The notebook's own folder is still the notebook.
607 fs::set_permissions(&root, fs::Permissions::from_mode(0o000)).unwrap();
608 let denied = notebook.refresh();
609 fs::set_permissions(&root, fs::Permissions::from_mode(0o755)).unwrap();
610 assert!(denied.is_err());
611}
612
613/// A local folder that notes each file read, with copies of some files by identity.
614struct Counting {
615 source: Local,
616 read: Vec<String>,
617 copies: Vec<([u8; 16], Vec<u8>)>,
618}
619
620impl Source for Counting {
621 fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>> {
622 self.source.entries(path, limit)
623 }
624
625 fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
626 self.read.push(path.to_owned());
627 self.source.read(path, limit)
628 }
629
630 fn copy(&mut self, _: &str, known: [u8; 16]) -> Option<Vec<u8>> {
631 let (_, copy) = self
632 .copies
633 .iter()
634 .find(|(identity, _)| *identity == known)?;
635 Some(copy.clone())
636 }
637}
638
639#[test]
640fn a_cached_discovery_reads_only_the_files_listed_otherwise() {
641 let root = tempfile::tempdir().unwrap();
642 fixture(root.path());
643 let two = |title| onestore::create_section("two.one", title, "Fixture").unwrap();
644 fs::write(root.path().join("two.one"), two("Two")).unwrap();
645 let mut source = Counting {
646 source: Local::open(root.path()).unwrap(),
647 read: Vec::new(),
648 copies: Vec::new(),
649 };
650 let mut cache = Cache::default();
651 let first = cache.discover(&mut source, limits()).unwrap();
652 assert_eq!(std::mem::take(&mut source.read).len(), 3);
653 let again = cache.discover(&mut source, limits()).unwrap();
654 assert!(source.read.is_empty(), "{:?}", source.read);
655 assert_eq!(
656 serde_json::to_value(&again).unwrap(),
657 serde_json::to_value(&first).unwrap()
658 );
659
660 let changed = two("Two, changed");
661 fs::write(root.path().join("two.one"), &changed).unwrap();
662 let after = cache.discover(&mut source, limits()).unwrap();
663 assert_eq!(std::mem::take(&mut source.read), ["two.one"]);
664 let fresh = discover(&mut Local::open(root.path()).unwrap(), limits()).unwrap();
665 assert_eq!(
666 serde_json::to_value(&after).unwrap(),
667 serde_json::to_value(&fresh).unwrap()
668 );
669 assert!(cache.found("two.one").unwrap().1 == onestore::Stamp::of(&changed).unwrap());
670
671 // Kept across launches, it reads nothing unchanged.
672 let mut kept: Cache = serde_json::from_slice(&serde_json::to_vec(&cache).unwrap()).unwrap();
673 kept.discover(&mut source, limits()).unwrap();
674 assert!(source.read.is_empty(), "{:?}", source.read);
675
676 // A file written as a copy of it holds, as a replica its own edits published from, is
677 // taken from the copy.
678 let copied = two("Two, published");
679 fs::write(root.path().join("two.one"), &copied).unwrap();
680 let identity = onestore::Store::parse(&changed).unwrap().header.file_id;
681 source.copies.push((identity, copied.clone()));
682 kept.discover(&mut source, limits()).unwrap();
683 assert!(source.read.is_empty(), "{:?}", source.read);
684 assert!(kept.found("two.one").unwrap().1 == onestore::Stamp::of(&copied).unwrap());
685
686 // A failed discovery leaves the cache as it was.
687 fs::write(root.path().join("Open Notebook.onetoc2"), b"unfinished").unwrap();
688 assert!(kept.discover(&mut source, limits()).is_err());
689 assert!(kept.found("two.one").is_some());
690}
691
692/// A share in memory, as a Mac's SMB client leaves it, that notes each file read. Finder
693/// writes another AppleDouble file into the root while discovery is under way.
694struct Share {
695 files: std::collections::BTreeMap<String, Vec<u8>>,
696 read: Vec<String>,
697 listings: usize,
698}
699
700impl Source for Share {
701 fn entries(&mut self, path: &str, _: usize) -> io::Result<Vec<Entry>> {
702 if path.is_empty() {
703 self.listings += 1;
704 if self.listings > 1 {
705 self.files
706 .insert("._Later.one".into(), b"\0\x05\x16\x07".to_vec());
707 }
708 }
709 let prefix = if path.is_empty() {
710 String::new()
711 } else {
712 format!("{path}/")
713 };
714 let mut entries: Vec<Entry> = Vec::new();
715 for (file, bytes) in &self.files {
716 let Some(rest) = file.strip_prefix(&prefix) else {
717 continue;
718 };
719 let (name, kind) = match rest.split_once('/') {
720 Some((folder, _)) => (folder, notebook::discover::EntryKind::Directory),
721 None => (rest, notebook::discover::EntryKind::File),
722 };
723 if entries.last().is_some_and(|last| last.name == name) {
724 continue;
725 }
726 entries.push(Entry {
727 name: name.into(),
728 kind,
729 listed: notebook::discover::Listed {
730 size: bytes.len() as u64,
731 modified: 1,
732 },
733 });
734 }
735 Ok(entries)
736 }
737
738 fn read(&mut self, path: &str, _: usize) -> io::Result<Vec<u8>> {
739 self.read.push(path.to_owned());
740 self.files
741 .get(path)
742 .cloned()
743 .ok_or_else(|| io::ErrorKind::NotFound.into())
744 }
745}
746
747#[test]
748fn metadata_other_systems_leave_on_a_share_is_not_the_notebook() {
749 let section = onestore::create_section("Section 003.one", "Kept", "Fixture").unwrap();
750 let identity = onestore::Store::parse(&section).unwrap().header.file_id;
751 let toc = onestore::create_table_of_contents(
752 "Open Notebook.onetoc2",
753 &[("Section 003.one", identity)],
754 )
755 .unwrap();
756 let inner = onestore::create_section("Inner.one", "Inner", "Fixture").unwrap();
757 // AppleDouble companions and `.DS_Store` begin with their own magic, not a OneNote header.
758 let apple_double = [&b"\0\x05\x16\x07\0\x02\0\0Mac OS X "[..], &[0; 4064]].concat();
759 let junk = [
760 ("._Section 003.one", apple_double.clone()),
761 ("._Open Notebook.onetoc2", apple_double.clone()),
762 (".DS_Store", b"\0\0\0\x01Bud1".to_vec()),
763 ("Thumbs.db", vec![0xd0, 0xcf, 0x11, 0xe0]),
764 ("desktop.ini", b"[.ShellClassInfo]".to_vec()),
765 ("~$Section 003.one", vec![7; 162]),
766 (".snowbound/tags.one", b"{}".to_vec()),
767 (".snowbound/Open Notebook.onetoc2", b"{}".to_vec()),
768 ("Group/._Inner.one", apple_double),
769 ("Group/.DS_Store", b"\0\0\0\x01Bud1".to_vec()),
770 ];
771 let mut source = Share {
772 files: [
773 ("Section 003.one", section),
774 ("Open Notebook.onetoc2", toc),
775 ("Group/Inner.one", inner),
776 ]
777 .into_iter()
778 .chain(junk)
779 .map(|(path, bytes)| (path.to_owned(), bytes))
780 .collect(),
781 read: Vec::new(),
782 listings: 0,
783 };
784 let catalog = discover(&mut source, limits()).unwrap();
785 assert_eq!(
786 catalog
787 .sections
788 .iter()
789 .map(|section| &section.path[..])
790 .collect::<Vec<_>>(),
791 ["Section 003.one"]
792 );
793 assert!(catalog.toc.unwrap().unresolved.is_empty());
794 assert_eq!(catalog.groups.len(), 1);
795 assert_eq!(catalog.groups[0].sections[0].path, "Group/Inner.one");
796 assert_eq!(catalog.groups[0].sections.len(), 1);
797 source.read.sort();
798 assert_eq!(
799 source.read,
800 [
801 "Group/Inner.one",
802 "Open Notebook.onetoc2",
803 "Section 003.one"
804 ]
805 );
806}
807
808#[test]
809fn a_discovery_holds_each_section_it_read_once() {
810 let root = tempfile::tempdir().unwrap();
811 let section = fixture(root.path());
812 let mut cache = Cache::default();
813 cache
814 .discover(&mut Local::open(root.path()).unwrap(), limits())
815 .unwrap();
816 let images = cache.take();
817 assert_eq!(images.keys().collect::<Vec<_>>(), ["one.one"]);
818 assert_eq!(images["one.one"], section);
819 assert!(cache.take().is_empty(), "taken once");
820 // Nothing read, nothing held.
821 cache
822 .discover(&mut Local::open(root.path()).unwrap(), limits())
823 .unwrap();
824 assert!(cache.take().is_empty());
825}