| 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)] |
| 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)] |
| 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 | 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. |
| 668 | fn 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 | |
| 708 | fn 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 | |
| 727 | fn 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. |
| 737 | pub(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 | |
| 746 | fn 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`. |
| 755 | fn 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. |
| 823 | fn 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. |
| 838 | fn foreign(name: &str) -> bool { |
| 839 | name.starts_with('.') || name.starts_with("~$") |
| 840 | } |
| 841 | |
| 842 | fn component(name: &str) -> bool { |
| 843 | !name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0']) |
| 844 | } |
| 845 | |
| 846 | fn join(parent: &str, name: &str) -> String { |
| 847 | if parent.is_empty() { |
| 848 | name.into() |
| 849 | } else { |
| 850 | format!("{parent}/{name}") |
| 851 | } |
| 852 | } |