| 1 | //! Read-only notebook discovery over a caller-supplied root. |
| 2 | |
| 3 | use onestore::{ |
| 4 | FileType, RevisionIndex, Stamp, Store, |
| 5 | document::{Document, Kind}, |
| 6 | }; |
| 7 | use serde::{Deserialize, Serialize}; |
| 8 | use std::{ |
| 9 | collections::{BTreeMap, BTreeSet}, |
| 10 | io, |
| 11 | time::Duration, |
| 12 | }; |
| 13 | |
| 14 | mod source; |
| 15 | pub use source::Local; |
| 16 | #[cfg(feature = "smb")] |
| 17 | pub use source::Smb; |
| 18 | pub(crate) use source::placeholder; |
| 19 | |
| 20 | #[derive(Debug, Serialize)] |
| 21 | pub 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)] |
| 33 | pub 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)] |
| 48 | pub 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 | |
| 60 | impl 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)] |
| 79 | pub 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)] |
| 87 | pub 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. |
| 102 | struct 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)] |
| 111 | pub 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)] |
| 121 | pub 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)] |
| 129 | pub 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)] |
| 139 | pub 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)] |
| 147 | pub 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)] |
| 157 | pub 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`. |
| 165 | const HELD: usize = 64 << 20; |
| 166 | /// How many more times a read that meets a commit in progress is tried, and how far apart. |
| 167 | const RETRIES: usize = 3; |
| 168 | const RETRY: Duration = Duration::from_millis(250); |
| 169 | |
| 170 | /// A notebook file as discovery read it. |
| 171 | #[derive(Clone, Serialize, Deserialize)] |
| 172 | struct 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)] |
| 182 | enum 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 | |
| 195 | impl 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 | |
| 229 | impl 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. |
| 247 | pub 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. |
| 265 | pub 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 | |
| 299 | pub struct Limits { |
| 300 | pub entries: usize, |
| 301 | pub bytes_per_file: usize, |
| 302 | pub depth: usize, |
| 303 | } |
| 304 | |
| 305 | #[derive(Debug, thiserror::Error)] |
| 306 | pub 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. |
| 329 | pub fn discover(source: &mut impl Source, limits: Limits) -> Result<Folder, Error> { |
| 330 | Cache::default().discover(source, limits) |
| 331 | } |
| 332 | |
| 333 | fn 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. |
| 676 | fn 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 | |
| 716 | fn 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 | |
| 735 | fn 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. |
| 745 | pub(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 | |
| 754 | fn 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`. |
| 763 | fn 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. |
| 831 | fn 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. |
| 845 | fn 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. |
| 863 | fn foreign(name: &str) -> bool { |
| 864 | name.starts_with('.') || name.starts_with("~$") |
| 865 | } |
| 866 | |
| 867 | fn component(name: &str) -> bool { |
| 868 | !name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0']) |
| 869 | } |
| 870 | |
| 871 | pub(crate) fn join(parent: &str, name: &str) -> String { |
| 872 | if parent.is_empty() { |
| 873 | name.into() |
| 874 | } else { |
| 875 | format!("{parent}/{name}") |
| 876 | } |
| 877 | } |