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/// A source whose files list as macOS lists those iCloud Drive evicted: dataless, under their
201/// names and listings, and never read.
202struct Dataless(Local);
203
204impl Source for Dataless {
205 fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>> {
206 let mut entries = self.0.entries(path, limit)?;
207 for entry in &mut entries {
208 entry.kind = notebook::discover::EntryKind::Evicted;
209 }
210 Ok(entries)
211 }
212
213 fn read(&mut self, path: &str, _: usize) -> io::Result<Vec<u8>> {
214 panic!("read {path}, which is not on this device");
215 }
216}
217
218/// A dataless file lists as its last read found it, where it lists unchanged since, and as not
219/// downloaded otherwise.
220#[test]
221fn dataless_files_list_as_last_read_or_wait_for_their_download() {
222 let root = tempfile::tempdir().unwrap();
223 fixture(root.path());
224 let mut cache = Cache::default();
225 cache
226 .discover(&mut Local::open(root.path()).unwrap(), limits())
227 .unwrap();
228 let catalog = cache
229 .discover(&mut Dataless(Local::open(root.path()).unwrap()), limits())
230 .unwrap();
231 assert_eq!(catalog.sections[0].path, "one.one");
232 assert!(catalog.toc.is_some() && catalog.unavailable.is_empty());
233
234 let catalog = discover(&mut Dataless(Local::open(root.path()).unwrap()), limits()).unwrap();
235 assert!(catalog.sections.is_empty() && catalog.toc.is_none());
236 assert_eq!(catalog.unavailable[0].path, "one.one");
237 assert_eq!(catalog.unavailable[0].reason, Reason::Evicted);
238}
239
240#[test]
241fn locked_and_unreadable_sections_retain_their_storage_identity() {
242 let root = tempfile::tempdir().unwrap();
243 for (name, fixture) in [
244 (
245 "locked.one",
246 "native-encrypted/encrypted-01/notebook/synthetic.one",
247 ),
248 (
249 "stub.one",
250 "native-encrypted/cold-encrypted-02/notebook/Open Notebook.one",
251 ),
252 ] {
253 fs::copy(
254 Path::new("../../corpus").join(fixture),
255 root.path().join(name),
256 )
257 .unwrap();
258 }
259 let catalog = discover(&mut Local::open(root.path()).unwrap(), limits()).unwrap();
260 assert!(catalog.toc.is_none());
261 assert!(matches!(
262 catalog.sections[0].state,
263 notebook::discover::SectionState::Locked
264 ));
265 assert!(matches!(
266 catalog.sections[1].state,
267 notebook::discover::SectionState::Unreadable(_)
268 ));
269 for section in catalog.sections {
270 let bytes = fs::read(root.path().join(section.path)).unwrap();
271 assert_eq!(
272 section.file_id,
273 onestore::Store::parse(&bytes).unwrap().header.file_id
274 );
275 }
276}
277
278#[test]
279fn a_group_rename_retains_toc_identity_and_parent_order() {
280 let root = tempfile::tempdir().unwrap();
281 let native = Path::new("../../corpus/m6/native-features-01/notebook");
282 for relative in [
283 "Open Notebook.onetoc2",
284 "Group A/Open Notebook.onetoc2",
285 "Group A/Duplicate.one",
286 "Group B/Open Notebook.onetoc2",
287 "Group B/Nested/Open Notebook.onetoc2",
288 "Group B/Nested/Duplicate.one",
289 ] {
290 let target = root.path().join(relative);
291 fs::create_dir_all(target.parent().unwrap()).unwrap();
292 fs::copy(native.join(relative), target).unwrap();
293 }
294 let before = discover(&mut Local::open(root.path()).unwrap(), limits()).unwrap();
295 assert_eq!(before.groups[0].path, "Group A");
296 fs::rename(root.path().join("Group A"), root.path().join("Renamed 🦀")).unwrap();
297 let catalog = discover(&mut Local::open(root.path()).unwrap(), limits()).unwrap();
298 assert_eq!(catalog.groups[0].path, "Renamed 🦀");
299 assert_eq!(
300 catalog.groups[0].toc.as_ref().unwrap().file_id,
301 before.groups[0].toc.as_ref().unwrap().file_id
302 );
303 assert_eq!(catalog.groups[1].path, "Group B");
304 assert_eq!(
305 catalog.groups[0].sections[0].path,
306 "Renamed 🦀/Duplicate.one"
307 );
308 assert_eq!(
309 serde_json::to_value(&catalog.toc.unwrap().unresolved).unwrap(),
310 serde_json::to_value(&before.toc.unwrap().unresolved).unwrap()
311 );
312}
313
314#[test]
315fn limits_and_incomplete_files_do_not_produce_a_catalog() {
316 let root = tempfile::tempdir().unwrap();
317 fixture(root.path());
318 let mut source = Local::open(root.path()).unwrap();
319 assert!(
320 discover(
321 &mut source,
322 Limits {
323 entries: 1,
324 ..limits()
325 }
326 )
327 .is_err()
328 );
329 assert!(
330 discover(
331 &mut source,
332 Limits {
333 bytes_per_file: 8,
334 ..limits()
335 }
336 )
337 .is_err()
338 );
339 fs::create_dir(root.path().join("group")).unwrap();
340 assert!(matches!(
341 discover(
342 &mut source,
343 Limits {
344 depth: 0,
345 ..limits()
346 }
347 ),
348 Err(Error::Limit { .. })
349 ));
350 fs::write(root.path().join("Open Notebook.onetoc2"), b"unfinished").unwrap();
351 assert!(
352 matches!(discover(&mut source, limits()), Err(Error::Document { path, .. }) if path == "Open Notebook.onetoc2")
353 );
354}
355
356#[test]
357fn a_section_that_cannot_be_read_lists_as_unavailable_and_the_rest_open() {
358 /// Answers as a share does a file mid-commit, as many times as `busy` counts for its
359 /// path, and fails the path `failure` names.
360 struct Busy {
361 source: Local,
362 busy: Vec<(&'static str, usize)>,
363 failure: Option<(&'static str, io::ErrorKind)>,
364 }
365 impl Source for Busy {
366 fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>> {
367 self.source.entries(path, limit)
368 }
369 fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
370 if let Some((_, kind)) = self.failure.filter(|(failing, _)| *failing == path) {
371 return Err(kind.into());
372 }
373 if let Some((_, left)) = self.busy.iter_mut().find(|(busy, _)| *busy == path)
374 && *left > 0
375 {
376 *left -= 1;
377 return Err(io::ErrorKind::WouldBlock.into());
378 }
379 self.source.read(path, limit)
380 }
381 }
382 let root = tempfile::tempdir().unwrap();
383 fixture(root.path());
384 for name in ["briefly.one", "stuck.one"] {
385 fs::write(
386 root.path().join(name),
387 onestore::create_section(name, "Text", "Fixture").unwrap(),
388 )
389 .unwrap();
390 }
391 fs::write(root.path().join("broken.one"), b"unfinished").unwrap();
392 let mut source = Busy {
393 source: Local::open(root.path()).unwrap(),
394 busy: vec![("briefly.one", 2), ("stuck.one", usize::MAX)],
395 failure: Some(("one.one", io::ErrorKind::InvalidData)),
396 };
397 let catalog = discover(&mut source, limits()).unwrap();
398 let sections: Vec<_> = catalog
399 .sections
400 .iter()
401 .map(|section| &section.path)
402 .collect();
403 assert_eq!(sections, ["briefly.one"]);
404 let unavailable: Vec<_> = catalog
405 .unavailable
406 .iter()
407 .map(|entry| (entry.path.as_str(), &entry.reason))
408 .collect();
409 assert_eq!(
410 unavailable,
411 [
412 ("broken.one", &Reason::Unreadable),
413 ("one.one", &Reason::Unreadable),
414 ("stuck.one", &Reason::InUse),
415 ]
416 );
417
418 // A lost connection still fails the whole discovery.
419 source.failure = Some(("one.one", io::ErrorKind::ConnectionAborted));
420 assert!(matches!(
421 discover(&mut source, limits()),
422 Err(Error::Io { path, error }) if path == "one.one" && error.kind() == io::ErrorKind::ConnectionAborted
423 ));
424}
425
426#[test]
427fn a_failed_refresh_retains_the_previous_catalog_as_an_independent_value() {
428 struct Changing {
429 source: Local,
430 reads: usize,
431 fail: bool,
432 }
433 impl Source for Changing {
434 fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>> {
435 self.reads += 1;
436 let mut entries = self.source.entries(path, limit)?;
437 if self.reads == 2 {
438 if self.fail {
439 return Err(io::ErrorKind::ConnectionAborted.into());
440 }
441 entries[0].name.push_str(".renamed");
442 }
443 Ok(entries)
444 }
445 fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
446 self.source.read(path, limit)
447 }
448 }
449 let root = tempfile::tempdir().unwrap();
450 fixture(root.path());
451 let mut source = Local::open(root.path()).unwrap();
452 let catalog = discover(&mut source, limits()).unwrap();
453 let before = serde_json::to_value(&catalog).unwrap();
454 for fail in [false, true] {
455 let mut changing = Changing {
456 source: Local::open(root.path()).unwrap(),
457 reads: 0,
458 fail,
459 };
460 let error = discover(&mut changing, limits()).unwrap_err();
461 if fail {
462 assert!(
463 matches!(error, Error::Io { error, .. } if error.kind() == io::ErrorKind::ConnectionAborted)
464 );
465 } else {
466 assert!(matches!(error, Error::Changed { .. }));
467 }
468 assert_eq!(serde_json::to_value(&catalog).unwrap(), before);
469 }
470}
471
472#[test]
473fn local_access_stays_inside_the_selected_root() {
474 let root = tempfile::tempdir().unwrap();
475 fixture(root.path());
476 let mut source = Local::open(root.path()).unwrap();
477 for path in [
478 "../one.one",
479 "/one.one",
480 "x/../one.one",
481 "one.one\0",
482 "x\\one.one",
483 ] {
484 assert_eq!(
485 source.read(path, 100).unwrap_err().kind(),
486 io::ErrorKind::InvalidInput
487 );
488 }
489 #[cfg(unix)]
490 {
491 let other = tempfile::tempdir().unwrap();
492 fs::write(other.path().join("other.one"), b"outside").unwrap();
493 std::os::unix::fs::symlink(other.path().join("other.one"), root.path().join("link.one"))
494 .unwrap();
495 assert_eq!(
496 source.read("link.one", 100).unwrap_err().kind(),
497 io::ErrorKind::PermissionDenied
498 );
499 assert!(
500 matches!(discover(&mut source, limits()), Err(Error::Entry { path }) if path == "link.one")
501 );
502 }
503}
504
505/// Restores permissions a test took away, so the temporary directory can be removed.
506#[cfg(unix)]
507struct Restore(Vec<(std::path::PathBuf, u32)>);
508
509#[cfg(unix)]
510impl Drop for Restore {
511 fn drop(&mut self) {
512 use std::os::unix::fs::PermissionsExt;
513 for (path, mode) in &self.0 {
514 let _ = fs::set_permissions(path, fs::Permissions::from_mode(*mode));
515 }
516 }
517}
518
519#[cfg(unix)]
520#[test]
521fn unreadable_children_are_listed_as_unavailable_and_retried() {
522 use std::os::unix::fs::PermissionsExt;
523 // Permission bits do not bind the superuser.
524 if unsafe { libc::geteuid() } == 0 {
525 return;
526 }
527 let directory = tempfile::tempdir().unwrap();
528 let root = directory.path().join("notebook");
529 fs::create_dir(&root).unwrap();
530 fixture(&root);
531 fs::create_dir(root.join("Art")).unwrap();
532 fs::create_dir(root.join("Group")).unwrap();
533 for name in ["kept.one", "denied.one"] {
534 fs::write(
535 root.join("Group").join(name),
536 onestore::create_section(name, "Text", "Fixture").unwrap(),
537 )
538 .unwrap();
539 }
540 fs::write(
541 root.join("Group/Open Notebook.onetoc2"),
542 onestore::create_table_of_contents("Open Notebook.onetoc2", &[]).unwrap(),
543 )
544 .unwrap();
545 let _restore = Restore(vec![
546 (root.join("Art"), 0o755),
547 (root.join("Group/denied.one"), 0o644),
548 ]);
549 for path in [root.join("Art"), root.join("Group/denied.one")] {
550 fs::set_permissions(path, fs::Permissions::from_mode(0o000)).unwrap();
551 }
552
553 let mut notebook =
554 notebook::session::Notebook::open(&root, directory.path().join("cache")).unwrap();
555 let catalog = notebook.catalog();
556 assert_eq!(catalog.sections.len(), 1);
557 assert_eq!(catalog.groups.len(), 1);
558 assert_eq!(catalog.unavailable.len(), 1);
559 assert_eq!(catalog.unavailable[0].path, "Art");
560 assert!(catalog.unavailable[0].group);
561 assert!(catalog.unavailable[0].error.contains("Permission denied"));
562 let group = &catalog.groups[0];
563 assert_eq!(group.sections.len(), 1);
564 assert_eq!(group.sections[0].path, "Group/kept.one");
565 assert_eq!(group.unavailable[0].path, "Group/denied.one");
566 assert!(!group.unavailable[0].group);
567
568 // An unavailable entry is not the catalog's to change, nor a group holding one.
569 assert!(notebook.delete("Art").is_err());
570 assert!(notebook.delete("Group").is_err());
571 assert!(root.join("Group/kept.one").exists());
572 assert!(notebook.rename("Art", "Other").is_err());
573 assert!(notebook.create_group("", "Art").is_err());
574
575 fs::set_permissions(root.join("Art"), fs::Permissions::from_mode(0o755)).unwrap();
576 notebook.refresh().unwrap();
577 assert!(notebook.catalog().unavailable.is_empty());
578 assert_eq!(notebook.catalog().groups.len(), 2);
579
580 // The notebook's own folder is still the notebook.
581 fs::set_permissions(&root, fs::Permissions::from_mode(0o000)).unwrap();
582 let denied = notebook.refresh();
583 fs::set_permissions(&root, fs::Permissions::from_mode(0o755)).unwrap();
584 assert!(denied.is_err());
585}
586
587/// A local folder that notes each file read, with copies of some files by identity.
588struct Counting {
589 source: Local,
590 read: Vec<String>,
591 copies: Vec<([u8; 16], Vec<u8>)>,
592}
593
594impl Source for Counting {
595 fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>> {
596 self.source.entries(path, limit)
597 }
598
599 fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
600 self.read.push(path.to_owned());
601 self.source.read(path, limit)
602 }
603
604 fn copy(&mut self, _: &str, known: [u8; 16]) -> Option<Vec<u8>> {
605 let (_, copy) = self
606 .copies
607 .iter()
608 .find(|(identity, _)| *identity == known)?;
609 Some(copy.clone())
610 }
611}
612
613#[test]
614fn a_cached_discovery_reads_only_the_files_listed_otherwise() {
615 let root = tempfile::tempdir().unwrap();
616 fixture(root.path());
617 let two = |title| onestore::create_section("two.one", title, "Fixture").unwrap();
618 fs::write(root.path().join("two.one"), two("Two")).unwrap();
619 let mut source = Counting {
620 source: Local::open(root.path()).unwrap(),
621 read: Vec::new(),
622 copies: Vec::new(),
623 };
624 let mut cache = Cache::default();
625 let first = cache.discover(&mut source, limits()).unwrap();
626 assert_eq!(std::mem::take(&mut source.read).len(), 3);
627 let again = cache.discover(&mut source, limits()).unwrap();
628 assert!(source.read.is_empty(), "{:?}", source.read);
629 assert_eq!(
630 serde_json::to_value(&again).unwrap(),
631 serde_json::to_value(&first).unwrap()
632 );
633
634 let changed = two("Two, changed");
635 fs::write(root.path().join("two.one"), &changed).unwrap();
636 let after = cache.discover(&mut source, limits()).unwrap();
637 assert_eq!(std::mem::take(&mut source.read), ["two.one"]);
638 let fresh = discover(&mut Local::open(root.path()).unwrap(), limits()).unwrap();
639 assert_eq!(
640 serde_json::to_value(&after).unwrap(),
641 serde_json::to_value(&fresh).unwrap()
642 );
643 assert!(cache.found("two.one").unwrap().1 == onestore::Stamp::of(&changed).unwrap());
644
645 // Kept across launches, it reads nothing unchanged.
646 let mut kept: Cache = serde_json::from_slice(&serde_json::to_vec(&cache).unwrap()).unwrap();
647 kept.discover(&mut source, limits()).unwrap();
648 assert!(source.read.is_empty(), "{:?}", source.read);
649
650 // A file written as a copy of it holds, as a replica its own edits published from, is
651 // taken from the copy.
652 let copied = two("Two, published");
653 fs::write(root.path().join("two.one"), &copied).unwrap();
654 let identity = onestore::Store::parse(&changed).unwrap().header.file_id;
655 source.copies.push((identity, copied.clone()));
656 kept.discover(&mut source, limits()).unwrap();
657 assert!(source.read.is_empty(), "{:?}", source.read);
658 assert!(kept.found("two.one").unwrap().1 == onestore::Stamp::of(&copied).unwrap());
659
660 // A failed discovery leaves the cache as it was.
661 fs::write(root.path().join("Open Notebook.onetoc2"), b"unfinished").unwrap();
662 assert!(kept.discover(&mut source, limits()).is_err());
663 assert!(kept.found("two.one").is_some());
664}
665
666/// A share in memory, as a Mac's SMB client leaves it, that notes each file read. Finder
667/// writes another AppleDouble file into the root while discovery is under way.
668struct Share {
669 files: std::collections::BTreeMap<String, Vec<u8>>,
670 read: Vec<String>,
671 listings: usize,
672}
673
674impl Source for Share {
675 fn entries(&mut self, path: &str, _: usize) -> io::Result<Vec<Entry>> {
676 if path.is_empty() {
677 self.listings += 1;
678 if self.listings > 1 {
679 self.files
680 .insert("._Later.one".into(), b"\0\x05\x16\x07".to_vec());
681 }
682 }
683 let prefix = if path.is_empty() {
684 String::new()
685 } else {
686 format!("{path}/")
687 };
688 let mut entries: Vec<Entry> = Vec::new();
689 for (file, bytes) in &self.files {
690 let Some(rest) = file.strip_prefix(&prefix) else {
691 continue;
692 };
693 let (name, kind) = match rest.split_once('/') {
694 Some((folder, _)) => (folder, notebook::discover::EntryKind::Directory),
695 None => (rest, notebook::discover::EntryKind::File),
696 };
697 if entries.last().is_some_and(|last| last.name == name) {
698 continue;
699 }
700 entries.push(Entry {
701 name: name.into(),
702 kind,
703 listed: notebook::discover::Listed {
704 size: bytes.len() as u64,
705 modified: 1,
706 },
707 });
708 }
709 Ok(entries)
710 }
711
712 fn read(&mut self, path: &str, _: usize) -> io::Result<Vec<u8>> {
713 self.read.push(path.to_owned());
714 self.files
715 .get(path)
716 .cloned()
717 .ok_or_else(|| io::ErrorKind::NotFound.into())
718 }
719}
720
721#[test]
722fn metadata_other_systems_leave_on_a_share_is_not_the_notebook() {
723 let section = onestore::create_section("Section 003.one", "Kept", "Fixture").unwrap();
724 let identity = onestore::Store::parse(&section).unwrap().header.file_id;
725 let toc = onestore::create_table_of_contents(
726 "Open Notebook.onetoc2",
727 &[("Section 003.one", identity)],
728 )
729 .unwrap();
730 let inner = onestore::create_section("Inner.one", "Inner", "Fixture").unwrap();
731 // AppleDouble companions and `.DS_Store` begin with their own magic, not a OneNote header.
732 let apple_double = [&b"\0\x05\x16\x07\0\x02\0\0Mac OS X "[..], &[0; 4064]].concat();
733 let junk = [
734 ("._Section 003.one", apple_double.clone()),
735 ("._Open Notebook.onetoc2", apple_double.clone()),
736 (".DS_Store", b"\0\0\0\x01Bud1".to_vec()),
737 ("Thumbs.db", vec![0xd0, 0xcf, 0x11, 0xe0]),
738 ("desktop.ini", b"[.ShellClassInfo]".to_vec()),
739 ("~$Section 003.one", vec![7; 162]),
740 (".snowbound/tags.one", b"{}".to_vec()),
741 (".snowbound/Open Notebook.onetoc2", b"{}".to_vec()),
742 ("Group/._Inner.one", apple_double),
743 ("Group/.DS_Store", b"\0\0\0\x01Bud1".to_vec()),
744 ];
745 let mut source = Share {
746 files: [
747 ("Section 003.one", section),
748 ("Open Notebook.onetoc2", toc),
749 ("Group/Inner.one", inner),
750 ]
751 .into_iter()
752 .chain(junk)
753 .map(|(path, bytes)| (path.to_owned(), bytes))
754 .collect(),
755 read: Vec::new(),
756 listings: 0,
757 };
758 let catalog = discover(&mut source, limits()).unwrap();
759 assert_eq!(
760 catalog
761 .sections
762 .iter()
763 .map(|section| &section.path[..])
764 .collect::<Vec<_>>(),
765 ["Section 003.one"]
766 );
767 assert!(catalog.toc.unwrap().unresolved.is_empty());
768 assert_eq!(catalog.groups.len(), 1);
769 assert_eq!(catalog.groups[0].sections[0].path, "Group/Inner.one");
770 assert_eq!(catalog.groups[0].sections.len(), 1);
771 source.read.sort();
772 assert_eq!(
773 source.read,
774 [
775 "Group/Inner.one",
776 "Open Notebook.onetoc2",
777 "Section 003.one"
778 ]
779 );
780}
781
782#[test]
783fn a_discovery_holds_each_section_it_read_once() {
784 let root = tempfile::tempdir().unwrap();
785 let section = fixture(root.path());
786 let mut cache = Cache::default();
787 cache
788 .discover(&mut Local::open(root.path()).unwrap(), limits())
789 .unwrap();
790 let images = cache.take();
791 assert_eq!(images.keys().collect::<Vec<_>>(), ["one.one"]);
792 assert_eq!(images["one.one"], section);
793 assert!(cache.take().is_empty(), "taken once");
794 // Nothing read, nothing held.
795 cache
796 .discover(&mut Local::open(root.path()).unwrap(), limits())
797 .unwrap();
798 assert!(cache.take().is_empty());
799}