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, Serialize, Deserialize)]
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, Serialize, Deserialize)]
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 'entries: 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| entry.kind == EntryKind::Evicted || known.listed == entry.listed)
412 .and_then(|known| Some((known.file_id()?, known)));
413 // A placeholder's metadata describes the placeholder, not the cached section.
414 if entry.kind == EntryKind::Evicted && reused.is_none() {
415 if expected == FileType::Section {
416 result.unavailable.push(Unavailable {
417 path: child,
418 group: false,
419 error: "Not downloaded to this device yet".into(),
420 reason: Reason::Evicted,
421 });
422 }
423 continue;
424 }
425 if !matches!(entry.kind, EntryKind::File | EntryKind::Evicted)
426 || (expected == FileType::TableOfContents && result.toc.is_some())
427 {
428 return Err(Error::Entry { path: child });
429 }
430 let (file_id, held) = match reused {
431 Some((file_id, known)) => {
432 found.read.insert(child.clone(), known.clone());
433 (file_id, Ok(known.held.clone()))
434 }
435 None => 'read: {
436 let copied = cached
437 .get(&child)
438 .and_then(Read::file_id)
439 .and_then(|known| source.copy(&child, known));
440 let fetched = copied.is_none();
441 // A section that cannot be read lists as unavailable; the rest still open.
442 let mut list_unavailable = |error: Error| match unavailable(&error) {
443 Some(reason) if expected == FileType::Section => {
444 result.unavailable.push(Unavailable {
445 path: child.clone(),
446 group: false,
447 error: error.to_string(),
448 reason,
449 });
450 Ok(())
451 }
452 _ => Err(error),
453 };
454 let read = copied.map_or_else(
455 || {
456 let mut read = source.read(&child, limits.bytes_per_file);
457 // A read that meets a commit in progress succeeds once it lands.
458 for _ in 0..RETRIES {
459 if !read.as_ref().is_err_and(|error| {
460 matches!(
461 error.kind(),
462 io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy
463 )
464 }) {
465 break;
466 }
467 std::thread::sleep(RETRY);
468 read = source.read(&child, limits.bytes_per_file);
469 }
470 read
471 },
472 Ok,
473 );
474 let bytes = match read {
475 Ok(bytes) => bytes,
476 Err(error) => {
477 // Listed but unreachable, as a cloud provider lists from its cache: the
478 // file as last read, read again next time.
479 if unreachable(&error)
480 && let Some((file_id, known)) = cached
481 .get(&child)
482 .and_then(|known| Some((known.file_id()?, known)))
483 {
484 found.read.insert(child.clone(), known.clone());
485 break 'read (file_id, Ok(known.held.clone()));
486 }
487 list_unavailable(Error::Io {
488 path: child.clone(),
489 error,
490 })?;
491 continue 'entries;
492 }
493 };
494 if bytes.len() > limits.bytes_per_file {
495 return Err(Error::Limit { path: child });
496 }
497 let parsed = Store::parse(&bytes).and_then(|store| {
498 if store.header.file_type != expected || !store.checksum_mismatches.is_empty() {
499 return Err(onestore::Error {
500 offset: 0,
501 message: "Unexpected file type or checksum mismatch",
502 });
503 }
504 Ok(store)
505 });
506 let store = match parsed {
507 Ok(store) => store,
508 Err(error) => {
509 list_unavailable(Error::Document {
510 path: child.clone(),
511 error,
512 })?;
513 continue 'entries;
514 }
515 };
516 let held = held(&store, expected);
517 let file_id = store.header.file_id;
518 if let Ok(held) = &held {
519 found.read.insert(
520 child.clone(),
521 Read {
522 listed: entry.listed,
523 header: bytes[..1024].iter().map(|b| format!("{b:02x}")).collect(),
524 length: bytes.len() as u64,
525 held: held.clone(),
526 },
527 );
528 let holding: usize = found.images.values().map(Vec::len).sum();
529 if fetched
530 && matches!(held, Held::Section { .. })
531 && holding + bytes.len() <= HELD
532 {
533 found.images.insert(child.clone(), bytes);
534 }
535 }
536 (file_id, held)
537 }
538 };
539 match held {
540 Ok(Held::Section {
541 name,
542 color,
543 document,
544 }) => result.sections.push(Section {
545 path: child.clone(),
546 file_id,
547 state: SectionState::Readable {
548 name,
549 color,
550 document,
551 },
552 copy: false,
553 }),
554 Ok(Held::Locked) => result.sections.push(Section {
555 path: child.clone(),
556 file_id,
557 state: SectionState::Locked,
558 copy: false,
559 }),
560 Ok(Held::Toc { unresolved, color }) => {
561 result.toc = Some(Toc {
562 filename: entry.name.clone(),
563 file_id,
564 unresolved,
565 color,
566 })
567 }
568 Err(error) if expected == FileType::Section => result.sections.push(Section {
569 path: child.clone(),
570 file_id,
571 state: SectionState::Unreadable(error),
572 copy: false,
573 }),
574 Err(error) => return Err(Error::Document { path: child, error }),
575 }
576 }
577 let order: BTreeMap<_, _> = result
578 .toc
579 .iter()
580 .flat_map(|toc| &toc.unresolved)
581 .map(|entry| (entry.file, entry.order))
582 .collect();
583 let rank = |file: Option<[u8; 16]>| file.and_then(|file| order.get(&file).copied());
584 let ranked = |file, path: &String| (rank(file).unwrap_or(u32::MAX), path.clone());
585 result
586 .sections
587 .sort_by_cached_key(|section| ranked(Some(section.file_id), &section.path));
588 let group_file = |group: &Folder| group.toc.as_ref().map(|toc| toc.file_id);
589 result
590 .groups
591 .sort_by_cached_key(|group| ranked(group_file(group), &group.path));
592 let mut entries: Vec<(u32, String)> = (result.sections.iter())
593 .map(|section| ranked(Some(section.file_id), &section.path))
594 .chain(
595 result
596 .groups
597 .iter()
598 .map(|group| ranked(group_file(group), &group.path)),
599 )
600 .collect();
601 entries.sort();
602 result.order = entries.into_iter().map(|(_, path)| path).collect();
603 let listed = |file: [u8; 16], path: &str| {
604 let name = path.rsplit('/').next().unwrap_or(path);
605 result
606 .toc
607 .iter()
608 .flat_map(|toc| &toc.unresolved)
609 .any(|entry| {
610 entry.file == file
611 && entry
612 .filename
613 .as_deref()
614 .is_some_and(|filename| filename.eq_ignore_ascii_case(name))
615 })
616 };
617 let modified = |path: &str| found.read.get(path).map_or(0, |read| read.listed.modified);
618 for section in &result.sections {
619 claims.entry(section.file_id).or_default().push(Claim {
620 path: section.path.clone(),
621 group: false,
622 listed: listed(section.file_id, &section.path),
623 modified: modified(&section.path),
624 });
625 }
626 for group in &result.groups {
627 if let Some(toc) = &group.toc {
628 claims.entry(toc.file_id).or_default().push(Claim {
629 path: group.path.clone(),
630 group: true,
631 listed: listed(toc.file_id, &group.path),
632 modified: modified(&join(&group.path, &toc.filename)),
633 });
634 }
635 }
636 let present: BTreeSet<_> = result
637 .sections
638 .iter()
639 .map(|section| section.file_id)
640 .chain(
641 result
642 .groups
643 .iter()
644 .filter_map(|group| group.toc.as_ref().map(|toc| toc.file_id)),
645 )
646 .collect();
647 if let Some(toc) = &mut result.toc {
648 toc.unresolved
649 .retain(|entry| !present.contains(&entry.file));
650 }
651 let mut observed = source
652 .entries(path, limits.entries)
653 .map_err(|error| Error::Io {
654 path: path.into(),
655 error,
656 })?;
657 observed.retain(|entry| !foreign(&entry.name));
658 observed.sort();
659 let names = |entries: &[Entry]| {
660 entries
661 .iter()
662 .map(|entry| (entry.name.clone(), entry.kind))
663 .collect::<Vec<_>>()
664 };
665 // A file written meanwhile lists otherwise, and is read again next time.
666 if names(&observed) != names(&listing) {
667 return Err(Error::Changed { path: path.into() });
668 }
669 Ok(result)
670}
671
672/// Keeps one group of each TOC identity in the catalog, the one its parent's TOC lists or else
673/// the newest; the others list as unavailable copies with their sections. Of sections holding
674/// one file, each lists, and all but the one its folder's TOC lists (or else the one with the
675/// shortest path) are marked as copies: a choice that stands while the files keep their names.
676fn set_aside(folder: &mut Folder, claims: &BTreeMap<[u8; 16], Vec<Claim>>) {
677 let mut copies = BTreeMap::new();
678 for claims in claims.values() {
679 let mut groups: Vec<_> = claims.iter().filter(|claim| claim.group).collect();
680 groups.sort_by_key(|claim| {
681 (
682 !claim.listed,
683 std::cmp::Reverse(claim.modified),
684 claim.path.len(),
685 &claim.path,
686 )
687 });
688 if let [original, rest @ ..] = &groups[..] {
689 for copy in rest {
690 copies.insert(copy.path.clone(), original.path.clone());
691 }
692 }
693 }
694 if !copies.is_empty() {
695 demote(folder, &copies);
696 }
697 let mut sections = BTreeSet::new();
698 for claims in claims.values() {
699 let mut held: Vec<_> = claims
700 .iter()
701 .filter(|claim| {
702 !claim.group
703 && !copies
704 .keys()
705 .any(|copy: &String| claim.path.starts_with(&format!("{copy}/")))
706 })
707 .collect();
708 held.sort_by_key(|claim| (!claim.listed, claim.path.len(), &claim.path));
709 sections.extend(held.iter().skip(1).map(|claim| claim.path.clone()));
710 }
711 if !sections.is_empty() {
712 mark(folder, &sections);
713 }
714}
715
716fn demote(folder: &mut Folder, copies: &BTreeMap<String, String>) {
717 folder.groups.retain(|group| {
718 let Some(of) = copies.get(&group.path) else {
719 return true;
720 };
721 let name = of.rsplit('/').next().unwrap_or(of);
722 folder.unavailable.push(Unavailable {
723 path: group.path.clone(),
724 group: true,
725 error: format!("A copy of \u{201c}{name}\u{201d}, which opens instead"),
726 reason: Reason::Copy { of: of.clone() },
727 });
728 false
729 });
730 for group in &mut folder.groups {
731 demote(group, copies);
732 }
733}
734
735fn mark(folder: &mut Folder, copies: &BTreeSet<String>) {
736 for section in &mut folder.sections {
737 section.copy = copies.contains(&section.path);
738 }
739 for group in &mut folder.groups {
740 mark(group, copies);
741 }
742}
743
744/// Whether `store` holds a password-protected section.
745pub(crate) fn locked(store: &Store) -> bool {
746 RevisionIndex::parse(store)
747 .and_then(|index| {
748 let document = Document::parse(&index)?;
749 Ok(encrypted(document.active(document.root)?))
750 })
751 .unwrap_or(false)
752}
753
754fn encrypted(revision: &onestore::document::Revision<'_>) -> bool {
755 revision
756 .roots
757 .get(&1)
758 .and_then(|id| revision.nodes.get(id))
759 .is_some_and(|node| matches!(node.kind, Kind::Encrypted { .. }))
760}
761
762/// What discovery takes from the file `store` holds, a section or a TOC as `expected`.
763fn held(store: &Store, expected: FileType) -> Result<Held, onestore::Error> {
764 let index = RevisionIndex::parse(store)?;
765 let document = Document::parse(&index)?;
766 let revision = document.active(document.root)?;
767 let root = |id| {
768 revision
769 .roots
770 .get(&id)
771 .and_then(|id| revision.nodes.get(id))
772 };
773 if expected == FileType::Section {
774 if encrypted(revision) {
775 return Ok(Held::Locked);
776 }
777 index.validate_current()?;
778 document.pages()?;
779 let (name, color) = root(2).map_or((None, None), |node| match &node.kind {
780 Kind::SectionMetadata { name, color } => (name.clone(), *color),
781 _ => (None, None),
782 });
783 return Ok(Held::Section {
784 name,
785 color: color.filter(|color| *color != 0xffff_ffff),
786 document: index.root.guid,
787 });
788 }
789 index.validate_current()?;
790 let Some(Kind::Toc { entries, color, .. }) = root(1).map(|node| &node.kind) else {
791 return Err(onestore::Error {
792 offset: 0,
793 message: "Missing notebook TOC root",
794 });
795 };
796 let mut seen = BTreeSet::new();
797 let mut unresolved = Vec::new();
798 for id in entries {
799 let Some(Kind::Toc {
800 filename,
801 identity: Some(file),
802 order: Some(order),
803 ..
804 }) = revision.nodes.get(id).map(|node| &node.kind)
805 else {
806 return Err(onestore::Error {
807 offset: 0,
808 message: "Incomplete notebook TOC reference",
809 });
810 };
811 if filename.as_deref().is_some_and(|name| !component(name)) || !seen.insert(*file) {
812 return Err(onestore::Error {
813 offset: 0,
814 message: "Invalid or duplicated notebook TOC reference",
815 });
816 }
817 unresolved.push(TocReference {
818 filename: filename.clone(),
819 file: *file,
820 order: *order,
821 });
822 }
823 Ok(Held::Toc {
824 unresolved,
825 color: *color,
826 })
827}
828
829/// Why a file or group that failed lists as unavailable; `None` fails the whole discovery,
830/// as a lost connection does.
831fn unavailable(error: &Error) -> Option<Reason> {
832 match error {
833 Error::Io { error, .. } => match error.kind() {
834 io::ErrorKind::PermissionDenied | io::ErrorKind::NotFound => Some(Reason::Denied),
835 io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy => Some(Reason::InUse),
836 io::ErrorKind::InvalidData | io::ErrorKind::FileTooLarge => Some(Reason::Unreadable),
837 _ => None,
838 },
839 Error::Document { .. } => Some(Reason::Unreadable),
840 _ => None,
841 }
842}
843
844/// Whether a file listed could not be read for want of a connection.
845fn unreachable(error: &io::Error) -> bool {
846 use io::ErrorKind::*;
847 matches!(
848 error.kind(),
849 NotConnected
850 | TimedOut
851 | ConnectionRefused
852 | ConnectionReset
853 | ConnectionAborted
854 | HostUnreachable
855 | NetworkUnreachable
856 | NetworkDown
857 | BrokenPipe
858 )
859}
860
861/// Not the notebook's: dot files, among them macOS's `.DS_Store` and AppleDouble `._`
862/// companions and Snowbound's `.snowbound` folder, and Office's `~$` owner files.
863fn foreign(name: &str) -> bool {
864 name.starts_with('.') || name.starts_with("~$")
865}
866
867fn component(name: &str) -> bool {
868 !name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0'])
869}
870
871pub(crate) fn join(parent: &str, name: &str) -> String {
872 if parent.is_empty() {
873 name.into()
874 } else {
875 format!("{parent}/{name}")
876 }
877}