1//! Read-only notebook discovery over a caller-supplied root.
2
3use onestore::{
4 FileType, RevisionIndex, Stamp, Store,
5 document::{Document, Kind},
6};
7use serde::{Deserialize, Serialize};
8use std::{
9 collections::{BTreeMap, BTreeSet},
10 io,
11 time::Duration,
12};
13
14mod source;
15pub use source::Local;
16#[cfg(feature = "smb")]
17pub use source::Smb;
18pub(crate) use source::placeholder;
19
20#[derive(Debug, Serialize)]
21pub struct Section {
22 pub path: String,
23 /// Header.guidFile, also used by FileIdentityGuid in a parent TOC.
24 pub file_id: [u8; 16],
25 pub state: SectionState,
26 /// Another section of the catalog holds the same file, as a copy made beside it does, and
27 /// is the one its folder's TOC lists by that identity (or else has the shorter path). OneNote
28 /// opens both; the copy's TOC entry, where one exists, is its own.
29 pub copy: bool,
30}
31
32#[derive(Debug, Serialize)]
33pub enum SectionState {
34 Readable {
35 /// An explicit SectionDisplayName; otherwise use the current filename without its extension.
36 name: Option<String>,
37 /// The tab colour as a COLORREF; OneNote assigns one when absent.
38 color: Option<u32>,
39 /// The root object space's GUID, the document identity that names the section's
40 /// replica in a mounted notebook.
41 document: [u8; 16],
42 },
43 Locked,
44 Unreadable(onestore::Error),
45}
46
47#[derive(Debug, Serialize)]
48pub struct Folder {
49 pub path: String,
50 pub toc: Option<Toc>,
51 pub sections: Vec<Section>,
52 pub groups: Vec<Folder>,
53 /// The paths of `sections` and `groups` together, in the order the TOC lists them, those
54 /// it doesn't last by path.
55 pub order: Vec<String>,
56 /// Child section files and groups that could not be read.
57 pub unavailable: Vec<Unavailable>,
58}
59
60impl Folder {
61 /// This folder and every group under it, in catalog order.
62 pub(crate) fn folders(&self) -> impl Iterator<Item = &Folder> {
63 let mut stack = vec![self];
64 std::iter::from_fn(move || {
65 let folder = stack.pop()?;
66 stack.extend(folder.groups.iter().rev());
67 Some(folder)
68 })
69 }
70
71 /// The sections of this folder and every group under it, in catalog order.
72 pub fn sections(&self) -> impl Iterator<Item = &Section> {
73 self.folders().flat_map(|folder| &folder.sections)
74 }
75}
76
77/// A section file or group folder denied or gone while listing; each discovery tries it again.
78#[derive(Debug, Serialize)]
79pub struct Unavailable {
80 pub path: String,
81 pub group: bool,
82 pub error: String,
83 pub reason: Reason,
84}
85
86#[derive(Debug, PartialEq, Eq, Serialize)]
87pub enum Reason {
88 /// Access denied, or gone mid-listing.
89 Denied,
90 /// Not yet on this device (`EntryKind::Evicted`), for the host to download.
91 Evicted,
92 /// Mid-write through every retry.
93 InUse,
94 /// Not a notebook file this can read: corrupt, or too large.
95 Unreadable,
96 /// Another group at `of` holds the same TOC; the catalog lists that one.
97 Copy { of: String },
98}
99
100/// A section file or group folder holding an identity, one of the copies discovery chooses
101/// among.
102struct Claim {
103 path: String,
104 group: bool,
105 /// Its folder's TOC lists the identity under its name.
106 listed: bool,
107 modified: u64,
108}
109
110#[derive(Debug, Serialize)]
111pub struct Toc {
112 pub filename: String,
113 pub file_id: [u8; 16],
114 /// Stale or unavailable TOC references; these are not inferred active sections.
115 pub unresolved: Vec<TocReference>,
116 /// The notebook's colour as a COLORREF, which only a notebook's own TOC holds.
117 pub color: Option<u32>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct TocReference {
122 /// Native TOCs can retain an identity after removing its cached filename.
123 pub filename: Option<String>,
124 pub file: [u8; 16],
125 pub order: u32,
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
129pub enum EntryKind {
130 File,
131 Directory,
132 Other,
133 /// A notebook file kept elsewhere and not yet on this device, as iCloud Drive's evicted
134 /// files list: a `.Name.icloud` placeholder, or on macOS 14 and later a dataless file.
135 Evicted,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
139pub struct Entry {
140 pub name: String,
141 pub kind: EntryKind,
142 pub listed: Listed,
143}
144
145/// A file as its folder's listing shows it: enough to tell that it changed without reading it.
146#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
147pub struct Listed {
148 pub size: u64,
149 /// When it was last written, in the source's own units.
150 pub modified: u64,
151}
152
153/// Each file a discovery read, with the listing it was read under, so that the next discovery
154/// reads only the files listed otherwise, as OneNote 2010 reopens a notebook it has cached.
155#[derive(Default, Serialize, Deserialize)]
156#[serde(transparent)]
157pub struct Cache {
158 read: BTreeMap<String, Read>,
159 /// The sections the last discovery read from its source, up to `HELD` bytes, until taken.
160 #[serde(skip)]
161 images: BTreeMap<String, Vec<u8>>,
162}
163
164/// The most bytes of images a cache holds for `Cache::take`.
165const HELD: usize = 64 << 20;
166/// How many more times a read that meets a commit in progress is tried, and how far apart.
167const RETRIES: usize = 3;
168const RETRY: Duration = Duration::from_millis(250);
169
170/// A notebook file as discovery read it.
171#[derive(Clone, Serialize, Deserialize)]
172struct Read {
173 listed: Listed,
174 /// The file's stamp: its header in hex, and its length.
175 header: String,
176 length: u64,
177 held: Held,
178}
179
180/// What discovery takes from a file.
181#[derive(Clone, Serialize, Deserialize)]
182enum Held {
183 Section {
184 name: Option<String>,
185 color: Option<u32>,
186 document: [u8; 16],
187 },
188 Locked,
189 Toc {
190 unresolved: Vec<TocReference>,
191 color: Option<u32>,
192 },
193}
194
195impl Cache {
196 /// `discover`, reading only the files listed otherwise than when this cache last read them.
197 /// A failed discovery leaves the cache as it was.
198 pub fn discover(&mut self, source: &mut impl Source, limits: Limits) -> Result<Folder, Error> {
199 let mut remaining = limits.entries;
200 let mut claims = BTreeMap::new();
201 let mut found = Cache::default();
202 let mut folder = scan(
203 source,
204 "",
205 &limits,
206 0,
207 &mut remaining,
208 &mut claims,
209 (&self.read, &mut found),
210 )?;
211 set_aside(&mut folder, &claims);
212 *self = found;
213 Ok(folder)
214 }
215
216 /// How the file at `path` was listed when last read, and its stamp then.
217 pub fn found(&self, path: &str) -> Option<(Listed, Stamp)> {
218 let read = self.read.get(path)?;
219 Some((read.listed, read.stamp()?))
220 }
221
222 /// The readable sections the last discovery read from its source, by path, once, so that
223 /// what reads them next need not read them again.
224 pub fn take(&mut self) -> BTreeMap<String, Vec<u8>> {
225 std::mem::take(&mut self.images)
226 }
227}
228
229impl Read {
230 fn stamp(&self) -> Option<Stamp> {
231 let header: Vec<u8> = (0..self.header.len())
232 .step_by(2)
233 .map(|at| u8::from_str_radix(self.header.get(at..at + 2)?, 16).ok())
234 .collect::<Option<_>>()?;
235 Some(Stamp {
236 header: header.try_into().ok()?,
237 length: self.length,
238 })
239 }
240
241 fn file_id(&self) -> Option<[u8; 16]> {
242 Some(onestore::Header::parse(&self.stamp()?.header).ok()?.file_id)
243 }
244}
245
246/// Rooted file access. Paths are relative, UTF-8, and use `/` between components.
247pub trait Source {
248 /// Return the complete immediate directory or an error, never a truncated success.
249 fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>>;
250 /// Return a consistent file snapshot, rejecting images larger than the byte limit.
251 fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>>;
252 /// Reads an external payload with the same completeness and size guarantees.
253 fn read_asset(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
254 self.read(path, limit)
255 }
256 /// The file at `path` as a local copy of the file whose identity was `known` holds it,
257 /// where the copy holds it as it stands now, so that it need not be read.
258 fn copy(&mut self, _path: &str, _known: [u8; 16]) -> Option<Vec<u8>> {
259 None
260 }
261}
262
263/// Reads an external file-data reference from the section's sibling `_onefiles` folder.
264/// `NotFound` in `Error::Io` is distinct from a successfully read zero-byte payload.
265pub fn read_external_asset(
266 source: &mut impl Source,
267 section: &str,
268 filename: &str,
269 limit: usize,
270) -> Result<Vec<u8>, Error> {
271 let (stem, extension) = section.rsplit_once('.').ok_or_else(|| Error::Entry {
272 path: section.into(),
273 })?;
274 if !extension.eq_ignore_ascii_case("one")
275 || !section.split('/').all(component)
276 || stem.ends_with('/')
277 || stem.is_empty()
278 {
279 return Err(Error::Entry {
280 path: section.into(),
281 });
282 }
283 format!("<file>{filename}")
284 .parse::<onestore::FileDataReference>()
285 .map_err(|_| Error::Entry {
286 path: filename.into(),
287 })?;
288 let path = format!("{stem}_onefiles/{filename}");
289 let bytes = source.read_asset(&path, limit).map_err(|error| Error::Io {
290 path: path.clone(),
291 error,
292 })?;
293 if bytes.len() > limit {
294 return Err(Error::Limit { path });
295 }
296 Ok(bytes)
297}
298
299pub struct Limits {
300 pub entries: usize,
301 pub bytes_per_file: usize,
302 pub depth: usize,
303}
304
305#[derive(Debug, thiserror::Error)]
306pub enum Error {
307 #[error("Cannot read {path}: {error}")]
308 Io {
309 path: String,
310 #[source]
311 error: io::Error,
312 },
313 #[error("Invalid notebook file {path}: {error}")]
314 Document {
315 path: String,
316 #[source]
317 error: onestore::Error,
318 },
319 #[error("Discovery limit exceeded at {path}")]
320 Limit { path: String },
321 #[error("Directory changed during discovery: {path}")]
322 Changed { path: String },
323 #[error("Invalid or unsupported directory entry: {path}")]
324 Entry { path: String },
325}
326
327/// Discovers rooted notebook topology within caller-specified work and size limits.
328/// Reserved `_onefiles` directories contain payloads, not section groups.
329pub fn discover(source: &mut impl Source, limits: Limits) -> Result<Folder, Error> {
330 Cache::default().discover(source, limits)
331}
332
333fn scan(
334 source: &mut impl Source,
335 path: &str,
336 limits: &Limits,
337 depth: usize,
338 remaining: &mut usize,
339 claims: &mut BTreeMap<[u8; 16], Vec<Claim>>,
340 (cached, found): (&BTreeMap<String, Read>, &mut Cache),
341) -> Result<Folder, Error> {
342 if depth > limits.depth {
343 return Err(Error::Limit { path: path.into() });
344 }
345 let mut listing = source
346 .entries(path, *remaining)
347 .map_err(|error| Error::Io {
348 path: path.into(),
349 error,
350 })?;
351 *remaining = remaining
352 .checked_sub(listing.len())
353 .ok_or_else(|| Error::Limit { path: path.into() })?;
354 listing.retain(|entry| !foreign(&entry.name));
355 listing.sort();
356 let mut names = BTreeSet::new();
357 for entry in &listing {
358 if !component(&entry.name) || !names.insert(&entry.name) {
359 return Err(Error::Entry {
360 path: join(path, &entry.name),
361 });
362 }
363 }
364 let mut result = Folder {
365 path: path.into(),
366 toc: None,
367 sections: Vec::new(),
368 groups: Vec::new(),
369 order: Vec::new(),
370 unavailable: Vec::new(),
371 };
372 for entry in &listing {
373 let child = join(path, &entry.name);
374 if entry.kind == EntryKind::Directory {
375 if entry.name.to_ascii_lowercase().ends_with("_onefiles") {
376 continue;
377 }
378 // A group whose own listing or TOC cannot be read is unavailable, not fatal.
379 match scan(
380 source,
381 &child,
382 limits,
383 depth + 1,
384 remaining,
385 claims,
386 (cached, found),
387 ) {
388 Ok(group) => result.groups.push(group),
389 Err(error) => match unavailable(&error) {
390 Some(reason) => result.unavailable.push(Unavailable {
391 path: child,
392 group: true,
393 error: error.to_string(),
394 reason,
395 }),
396 None => return Err(error),
397 },
398 }
399 continue;
400 }
401 let lower = entry.name.to_ascii_lowercase();
402 let expected = if lower.ends_with(".one") {
403 FileType::Section
404 } else if lower.ends_with(".onetoc2") {
405 FileType::TableOfContents
406 } else {
407 continue;
408 };
409 let reused = cached
410 .get(&child)
411 .filter(|known| known.listed == entry.listed)
412 .and_then(|known| Some((known.file_id()?, known)));
413 // An evicted file listed as it was last read lists as then. Without an evicted TOC the
414 // folder lists in path order; nothing writes a second one, as its placeholder, or the
415 // dataless file itself, blocks creating it.
416 if entry.kind == EntryKind::Evicted && reused.is_none() {
417 if expected == FileType::Section {
418 result.unavailable.push(Unavailable {
419 path: child,
420 group: false,
421 error: "Not downloaded to this device yet".into(),
422 reason: Reason::Evicted,
423 });
424 }
425 continue;
426 }
427 if !matches!(entry.kind, EntryKind::File | EntryKind::Evicted)
428 || (expected == FileType::TableOfContents && result.toc.is_some())
429 {
430 return Err(Error::Entry { path: child });
431 }
432 let (file_id, held) = match reused {
433 Some((file_id, known)) => {
434 found.read.insert(child.clone(), known.clone());
435 (file_id, Ok(known.held.clone()))
436 }
437 None => {
438 let copied = cached
439 .get(&child)
440 .and_then(Read::file_id)
441 .and_then(|known| source.copy(&child, known));
442 let fetched = copied.is_none();
443 // A section that cannot be read lists as unavailable; the rest still open.
444 let mut list_unavailable = |error: Error| match unavailable(&error) {
445 Some(reason) if expected == FileType::Section => {
446 result.unavailable.push(Unavailable {
447 path: child.clone(),
448 group: false,
449 error: error.to_string(),
450 reason,
451 });
452 Ok(())
453 }
454 _ => Err(error),
455 };
456 let read = copied.map_or_else(
457 || {
458 let mut read = source.read(&child, limits.bytes_per_file);
459 // A read that meets a commit in progress succeeds once it lands.
460 for _ in 0..RETRIES {
461 if !read.as_ref().is_err_and(|error| {
462 matches!(
463 error.kind(),
464 io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy
465 )
466 }) {
467 break;
468 }
469 std::thread::sleep(RETRY);
470 read = source.read(&child, limits.bytes_per_file);
471 }
472 read
473 },
474 Ok,
475 );
476 let bytes = match read {
477 Ok(bytes) => bytes,
478 Err(error) => {
479 list_unavailable(Error::Io {
480 path: child.clone(),
481 error,
482 })?;
483 continue;
484 }
485 };
486 if bytes.len() > limits.bytes_per_file {
487 return Err(Error::Limit { path: child });
488 }
489 let parsed = Store::parse(&bytes).and_then(|store| {
490 if store.header.file_type != expected || !store.checksum_mismatches.is_empty() {
491 return Err(onestore::Error {
492 offset: 0,
493 message: "Unexpected file type or checksum mismatch",
494 });
495 }
496 Ok(store)
497 });
498 let store = match parsed {
499 Ok(store) => store,
500 Err(error) => {
501 list_unavailable(Error::Document {
502 path: child.clone(),
503 error,
504 })?;
505 continue;
506 }
507 };
508 let held = held(&store, expected);
509 let file_id = store.header.file_id;
510 if let Ok(held) = &held {
511 found.read.insert(
512 child.clone(),
513 Read {
514 listed: entry.listed,
515 header: bytes[..1024].iter().map(|b| format!("{b:02x}")).collect(),
516 length: bytes.len() as u64,
517 held: held.clone(),
518 },
519 );
520 let holding: usize = found.images.values().map(Vec::len).sum();
521 if fetched
522 && matches!(held, Held::Section { .. })
523 && holding + bytes.len() <= HELD
524 {
525 found.images.insert(child.clone(), bytes);
526 }
527 }
528 (file_id, held)
529 }
530 };
531 match held {
532 Ok(Held::Section {
533 name,
534 color,
535 document,
536 }) => result.sections.push(Section {
537 path: child.clone(),
538 file_id,
539 state: SectionState::Readable {
540 name,
541 color,
542 document,
543 },
544 copy: false,
545 }),
546 Ok(Held::Locked) => result.sections.push(Section {
547 path: child.clone(),
548 file_id,
549 state: SectionState::Locked,
550 copy: false,
551 }),
552 Ok(Held::Toc { unresolved, color }) => {
553 result.toc = Some(Toc {
554 filename: entry.name.clone(),
555 file_id,
556 unresolved,
557 color,
558 })
559 }
560 Err(error) if expected == FileType::Section => result.sections.push(Section {
561 path: child.clone(),
562 file_id,
563 state: SectionState::Unreadable(error),
564 copy: false,
565 }),
566 Err(error) => return Err(Error::Document { path: child, error }),
567 }
568 }
569 let order: BTreeMap<_, _> = result
570 .toc
571 .iter()
572 .flat_map(|toc| &toc.unresolved)
573 .map(|entry| (entry.file, entry.order))
574 .collect();
575 let rank = |file: Option<[u8; 16]>| file.and_then(|file| order.get(&file).copied());
576 let ranked = |file, path: &String| (rank(file).unwrap_or(u32::MAX), path.clone());
577 result
578 .sections
579 .sort_by_cached_key(|section| ranked(Some(section.file_id), &section.path));
580 let group_file = |group: &Folder| group.toc.as_ref().map(|toc| toc.file_id);
581 result
582 .groups
583 .sort_by_cached_key(|group| ranked(group_file(group), &group.path));
584 let mut entries: Vec<(u32, String)> = (result.sections.iter())
585 .map(|section| ranked(Some(section.file_id), &section.path))
586 .chain(
587 result
588 .groups
589 .iter()
590 .map(|group| ranked(group_file(group), &group.path)),
591 )
592 .collect();
593 entries.sort();
594 result.order = entries.into_iter().map(|(_, path)| path).collect();
595 let listed = |file: [u8; 16], path: &str| {
596 let name = path.rsplit('/').next().unwrap_or(path);
597 result
598 .toc
599 .iter()
600 .flat_map(|toc| &toc.unresolved)
601 .any(|entry| {
602 entry.file == file
603 && entry
604 .filename
605 .as_deref()
606 .is_some_and(|filename| filename.eq_ignore_ascii_case(name))
607 })
608 };
609 let modified = |path: &str| found.read.get(path).map_or(0, |read| read.listed.modified);
610 for section in &result.sections {
611 claims.entry(section.file_id).or_default().push(Claim {
612 path: section.path.clone(),
613 group: false,
614 listed: listed(section.file_id, &section.path),
615 modified: modified(&section.path),
616 });
617 }
618 for group in &result.groups {
619 if let Some(toc) = &group.toc {
620 claims.entry(toc.file_id).or_default().push(Claim {
621 path: group.path.clone(),
622 group: true,
623 listed: listed(toc.file_id, &group.path),
624 modified: modified(&join(&group.path, &toc.filename)),
625 });
626 }
627 }
628 let present: BTreeSet<_> = result
629 .sections
630 .iter()
631 .map(|section| section.file_id)
632 .chain(
633 result
634 .groups
635 .iter()
636 .filter_map(|group| group.toc.as_ref().map(|toc| toc.file_id)),
637 )
638 .collect();
639 if let Some(toc) = &mut result.toc {
640 toc.unresolved
641 .retain(|entry| !present.contains(&entry.file));
642 }
643 let mut observed = source
644 .entries(path, limits.entries)
645 .map_err(|error| Error::Io {
646 path: path.into(),
647 error,
648 })?;
649 observed.retain(|entry| !foreign(&entry.name));
650 observed.sort();
651 let names = |entries: &[Entry]| {
652 entries
653 .iter()
654 .map(|entry| (entry.name.clone(), entry.kind))
655 .collect::<Vec<_>>()
656 };
657 // A file written meanwhile lists otherwise, and is read again next time.
658 if names(&observed) != names(&listing) {
659 return Err(Error::Changed { path: path.into() });
660 }
661 Ok(result)
662}
663
664/// Keeps one group of each TOC identity in the catalog, the one its parent's TOC lists or else
665/// the newest; the others list as unavailable copies with their sections. Of sections holding
666/// one file, each lists, and all but the one its folder's TOC lists (or else the one with the
667/// shortest path) are marked as copies: a choice that stands while the files keep their names.
668fn set_aside(folder: &mut Folder, claims: &BTreeMap<[u8; 16], Vec<Claim>>) {
669 let mut copies = BTreeMap::new();
670 for claims in claims.values() {
671 let mut groups: Vec<_> = claims.iter().filter(|claim| claim.group).collect();
672 groups.sort_by_key(|claim| {
673 (
674 !claim.listed,
675 std::cmp::Reverse(claim.modified),
676 claim.path.len(),
677 &claim.path,
678 )
679 });
680 if let [original, rest @ ..] = &groups[..] {
681 for copy in rest {
682 copies.insert(copy.path.clone(), original.path.clone());
683 }
684 }
685 }
686 if !copies.is_empty() {
687 demote(folder, &copies);
688 }
689 let mut sections = BTreeSet::new();
690 for claims in claims.values() {
691 let mut held: Vec<_> = claims
692 .iter()
693 .filter(|claim| {
694 !claim.group
695 && !copies
696 .keys()
697 .any(|copy: &String| claim.path.starts_with(&format!("{copy}/")))
698 })
699 .collect();
700 held.sort_by_key(|claim| (!claim.listed, claim.path.len(), &claim.path));
701 sections.extend(held.iter().skip(1).map(|claim| claim.path.clone()));
702 }
703 if !sections.is_empty() {
704 mark(folder, &sections);
705 }
706}
707
708fn demote(folder: &mut Folder, copies: &BTreeMap<String, String>) {
709 folder.groups.retain(|group| {
710 let Some(of) = copies.get(&group.path) else {
711 return true;
712 };
713 let name = of.rsplit('/').next().unwrap_or(of);
714 folder.unavailable.push(Unavailable {
715 path: group.path.clone(),
716 group: true,
717 error: format!("A copy of \u{201c}{name}\u{201d}, which opens instead"),
718 reason: Reason::Copy { of: of.clone() },
719 });
720 false
721 });
722 for group in &mut folder.groups {
723 demote(group, copies);
724 }
725}
726
727fn mark(folder: &mut Folder, copies: &BTreeSet<String>) {
728 for section in &mut folder.sections {
729 section.copy = copies.contains(&section.path);
730 }
731 for group in &mut folder.groups {
732 mark(group, copies);
733 }
734}
735
736/// Whether `store` holds a password-protected section.
737pub(crate) fn locked(store: &Store) -> bool {
738 RevisionIndex::parse(store)
739 .and_then(|index| {
740 let document = Document::parse(&index)?;
741 Ok(encrypted(document.active(document.root)?))
742 })
743 .unwrap_or(false)
744}
745
746fn encrypted(revision: &onestore::document::Revision<'_>) -> bool {
747 revision
748 .roots
749 .get(&1)
750 .and_then(|id| revision.nodes.get(id))
751 .is_some_and(|node| matches!(node.kind, Kind::Encrypted { .. }))
752}
753
754/// What discovery takes from the file `store` holds, a section or a TOC as `expected`.
755fn held(store: &Store, expected: FileType) -> Result<Held, onestore::Error> {
756 let index = RevisionIndex::parse(store)?;
757 let document = Document::parse(&index)?;
758 let revision = document.active(document.root)?;
759 let root = |id| {
760 revision
761 .roots
762 .get(&id)
763 .and_then(|id| revision.nodes.get(id))
764 };
765 if expected == FileType::Section {
766 if encrypted(revision) {
767 return Ok(Held::Locked);
768 }
769 index.validate_current()?;
770 document.pages()?;
771 let (name, color) = root(2).map_or((None, None), |node| match &node.kind {
772 Kind::SectionMetadata { name, color } => (name.clone(), *color),
773 _ => (None, None),
774 });
775 return Ok(Held::Section {
776 name,
777 color: color.filter(|color| *color != 0xffff_ffff),
778 document: index.root.guid,
779 });
780 }
781 index.validate_current()?;
782 let Some(Kind::Toc { entries, color, .. }) = root(1).map(|node| &node.kind) else {
783 return Err(onestore::Error {
784 offset: 0,
785 message: "Missing notebook TOC root",
786 });
787 };
788 let mut seen = BTreeSet::new();
789 let mut unresolved = Vec::new();
790 for id in entries {
791 let Some(Kind::Toc {
792 filename,
793 identity: Some(file),
794 order: Some(order),
795 ..
796 }) = revision.nodes.get(id).map(|node| &node.kind)
797 else {
798 return Err(onestore::Error {
799 offset: 0,
800 message: "Incomplete notebook TOC reference",
801 });
802 };
803 if filename.as_deref().is_some_and(|name| !component(name)) || !seen.insert(*file) {
804 return Err(onestore::Error {
805 offset: 0,
806 message: "Invalid or duplicated notebook TOC reference",
807 });
808 }
809 unresolved.push(TocReference {
810 filename: filename.clone(),
811 file: *file,
812 order: *order,
813 });
814 }
815 Ok(Held::Toc {
816 unresolved,
817 color: *color,
818 })
819}
820
821/// Why a file or group that failed lists as unavailable; `None` fails the whole discovery,
822/// as a lost connection does.
823fn unavailable(error: &Error) -> Option<Reason> {
824 match error {
825 Error::Io { error, .. } => match error.kind() {
826 io::ErrorKind::PermissionDenied | io::ErrorKind::NotFound => Some(Reason::Denied),
827 io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy => Some(Reason::InUse),
828 io::ErrorKind::InvalidData | io::ErrorKind::FileTooLarge => Some(Reason::Unreadable),
829 _ => None,
830 },
831 Error::Document { .. } => Some(Reason::Unreadable),
832 _ => None,
833 }
834}
835
836/// Not the notebook's: dot files, among them macOS's `.DS_Store` and AppleDouble `._`
837/// companions and Snowbound's `.snowbound` folder, and Office's `~$` owner files.
838fn foreign(name: &str) -> bool {
839 name.starts_with('.') || name.starts_with("~$")
840}
841
842fn component(name: &str) -> bool {
843 !name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0'])
844}
845
846fn join(parent: &str, name: &str) -> String {
847 if parent.is_empty() {
848 name.into()
849 } else {
850 format!("{parent}/{name}")
851 }
852}