1//! Password-protected sections: unlocking, sealed queues, publishing, merging and conflict
2//! pages, and setting, changing and removing a password.
3
4use notebook::{EditStatus, Replica, discover::SectionState, session::Notebook};
5use onestore::{
6 Arena, ExGuid, Section,
7 op::{Edit, Op, PageOp},
8 page::{Page, PageObject},
9 protected::{Error, Key, rekey},
10};
11use std::path::Path;
12
13#[path = "support/server.rs"]
14mod server;
15use server::Server;
16
17fn fixture(name: &str) -> (std::path::PathBuf, String) {
18 let root = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus")).join(name);
19 let manifest: serde_json::Value =
20 serde_json::from_slice(&std::fs::read(root.join("manifest.json")).unwrap()).unwrap();
21 (root, manifest["password"].as_str().unwrap().to_owned())
22}
23
24/// A copy of a notebook folder.
25fn copy(from: &Path, to: &Path) {
26 std::fs::create_dir_all(to).unwrap();
27 for entry in std::fs::read_dir(from).unwrap() {
28 let entry = entry.unwrap();
29 std::fs::copy(entry.path(), to.join(entry.file_name())).unwrap();
30 }
31}
32
33/// A protected one-page section holding `text`, with its key.
34fn protected(text: &str) -> (Vec<u8>, Key) {
35 let plain = onestore::create_section("sealed.one", text, "Fixture").unwrap();
36 let key = Key::new("fixture password").unwrap();
37 (rekey(&plain, None, Some(&key)).unwrap(), key)
38}
39
40/// The first text object of `image` with its page.
41fn first_text(image: &[u8], key: &Key) -> (ExGuid, ExGuid, String) {
42 let arena = Arena::default();
43 let mut section = Section::unlock(&arena, image.to_vec(), key).unwrap();
44 let (space, ..) = section.pages().unwrap()[0];
45 let page = section.page(space).unwrap();
46 texts(&page)
47 .into_iter()
48 .next()
49 .map(|(id, text)| (space, id, text))
50 .unwrap()
51}
52
53fn texts(page: &Page) -> Vec<(ExGuid, String)> {
54 page.objects
55 .iter()
56 .filter_map(|object| match object {
57 PageObject::Outline(outline) => Some(&outline.paragraphs),
58 _ => None,
59 })
60 .flatten()
61 .filter_map(|paragraph| {
62 let text = paragraph.text()?;
63 Some((text.id, text.text.text().to_owned()))
64 })
65 .collect()
66}
67
68fn replace(space: ExGuid, text: ExGuid, range: std::ops::Range<u32>, with: &str) -> Edit {
69 Edit {
70 at: 134_000_000_000_000_000,
71 ops: vec![Op::Page {
72 space,
73 op: PageOp::Text {
74 text,
75 range,
76 with: with.into(),
77 },
78 }],
79 }
80}
81
82/// Whether any file of a replica holds `clear`, as UTF-8 or UTF-16.
83fn leaks(replica: &Path, clear: &str) -> bool {
84 let utf16: Vec<u8> = clear.encode_utf16().flat_map(u16::to_le_bytes).collect();
85 ["", "-wal", "-shm"].iter().any(|suffix| {
86 let mut file = replica.as_os_str().to_owned();
87 file.push(suffix);
88 std::fs::read(file).is_ok_and(|bytes| {
89 [clear.as_bytes(), &utf16[..]]
90 .iter()
91 .any(|clear| bytes.windows(clear.len()).any(|w| w == *clear))
92 })
93 })
94}
95
96/// A locked section is discovered as such, unlocks with its password and opens as a session.
97#[test]
98fn a_locked_section_unlocks_into_a_session() {
99 let (root, password) = fixture("native-protected-boundaries");
100 let temporary = tempfile::tempdir().unwrap();
101 let folder = temporary.path().join("notebook");
102 copy(&root.join("notebook"), &folder);
103 let notebook = Notebook::open(&folder, temporary.path().join("cache")).unwrap();
104 let section = &notebook.catalog().sections[0];
105 assert!(matches!(section.state, SectionState::Locked));
106 assert!(matches!(
107 notebook.unlock(&section.path, "wrong"),
108 Err(notebook::Error::Protected(Error::PasswordMismatch))
109 ));
110 assert!(notebook.section(&section.path, || {}).is_err());
111 let key = notebook.unlock(&section.path, &password).unwrap();
112 let session = notebook
113 .section_unlocked(&section.path, &key, || {})
114 .unwrap();
115 let pages = session.pages().unwrap();
116 assert_eq!(pages.len(), 11);
117 assert!(pages.iter().all(|(_, title, _)| !title.is_empty()));
118 let title = &pages[0].1;
119 session.close().unwrap();
120 // The replica holds the section as its file does: encrypted.
121 assert!(!leaks(
122 &notebook.replica_path(&section.path).unwrap(),
123 title
124 ));
125}
126
127/// Queued edits and their payloads are sealed in the replica, which opens again only under
128/// the section's key, and publish into the file under it.
129#[test]
130fn queued_edits_are_sealed_and_publish_under_the_key() {
131 const SECRET: &str = "Queued in confidence";
132 let (source, key) = protected("Opening line");
133 let (space, text, _) = first_text(&source, &key);
134 let directory = tempfile::tempdir().unwrap();
135 let path = directory.path().join("cache.sqlite");
136 let cache = Replica::open_or_create(&path, Some(&key), || Ok(source.clone())).unwrap();
137 cache
138 .apply("Fixture", replace(space, text, 0..0, SECRET))
139 .unwrap();
140 let mut page = cache.page(space).unwrap();
141 let PageObject::Outline(outline) = page
142 .objects
143 .iter_mut()
144 .find(|object| matches!(object, PageObject::Outline(_)))
145 .unwrap()
146 else {
147 unreachable!()
148 };
149 let mut file = outline.paragraphs[0].clone();
150 file.id = onestore::page::text::new_id().unwrap();
151 file.content = onestore::page::ParagraphContent::Attachment(onestore::page::Attachment {
152 id: onestore::page::text::new_id().unwrap(),
153 filename: "sealed.txt".into(),
154 source_path: None,
155 size: Some([24.0, 24.0]),
156 layout: Default::default(),
157 bytes: Some(std::sync::Arc::from(SECRET.as_bytes())),
158 preview: None,
159 recording: None,
160 tags: Vec::new(),
161 });
162 outline.paragraphs.push(file);
163 let before = cache.page(space).unwrap();
164 let ops = onestore::op::lower_page(&before, &page).unwrap();
165 cache
166 .apply(
167 "Fixture",
168 Edit {
169 at: 134_000_000_000_000_001,
170 ops: ops.into_iter().map(|op| Op::Page { space, op }).collect(),
171 },
172 )
173 .unwrap();
174 drop(cache);
175 assert!(!leaks(&path, SECRET));
176 assert!(Replica::open(&path).is_err());
177 assert!(
178 Replica::open_or_create(
179 &path,
180 Some(&Key::new("another").unwrap()),
181 || unreachable!()
182 )
183 .is_err()
184 );
185 let cache = Replica::open_or_create(&path, Some(&key), || unreachable!()).unwrap();
186 assert_eq!(cache.pending().unwrap().len(), 2);
187 let mut server = Server::new(&source);
188 assert!(matches!(
189 cache.sync_once(&mut server).unwrap().edit,
190 Some((_, EditStatus::Published { .. }))
191 ));
192 drop(cache);
193 assert!(!leaks(&path, SECRET));
194 assert!(
195 !server
196 .durable
197 .windows(SECRET.len())
198 .any(|w| w == SECRET.as_bytes())
199 );
200 let arena = Arena::default();
201 let stored = Section::unlock(&arena, server.durable.clone(), &key).unwrap();
202 let published = stored.page(space).unwrap();
203 assert!(texts(&published)[0].1.starts_with(SECRET));
204 assert!(format!("{published:?}").contains("sealed.txt"));
205 let archived = directory.path().join("recovery.sqlite");
206 Replica::open_or_create(&path, Some(&key), || unreachable!())
207 .unwrap()
208 .export_recovery(&archived)
209 .unwrap();
210 assert!(!leaks(&archived, SECRET));
211 assert!(notebook::Recovery::open(&archived).is_err());
212 assert!(notebook::Recovery::open_unlocked(&archived, &key).is_ok());
213}
214
215/// Edits of the same text on two copies of a protected section merge as an ordinary
216/// section's do: the remote's stays the page, the local one becomes a conflict page, and
217/// every revision stays under the key.
218#[test]
219fn a_conflict_in_a_protected_section_becomes_a_conflict_page() {
220 let (source, key) = protected("alpha beta gamma");
221 let (space, text, _) = first_text(&source, &key);
222 let directory = tempfile::tempdir().unwrap();
223 let cache = Replica::open_or_create(directory.path().join("c.sqlite"), Some(&key), || {
224 Ok(source.clone())
225 })
226 .unwrap();
227 cache
228 .apply("Local", replace(space, text, 6..10, "LOCAL"))
229 .unwrap();
230 let remote = {
231 let arena = Arena::default();
232 let mut section = Section::unlock(&arena, source.clone(), &key).unwrap();
233 section
234 .apply("Remote", &replace(space, text, 6..10, "REMOTE"))
235 .unwrap();
236 section.seal().unwrap();
237 section.image()
238 };
239 let mut server = Server::new(&remote);
240 assert!(matches!(
241 cache.sync_once(&mut server).unwrap().edit,
242 Some((_, EditStatus::Published { .. }))
243 ));
244 let arena = Arena::default();
245 let mut merged = Section::unlock(&arena, server.durable.clone(), &key).unwrap();
246 assert_eq!(
247 texts(&merged.page(space).unwrap())[0].1,
248 "alpha REMOTE gamma"
249 );
250 let conflicts = merged.conflicts().unwrap();
251 assert_eq!(conflicts.len(), 1);
252 let conflict = merged.page(conflicts[0].1[0].space).unwrap();
253 assert_eq!(texts(&conflict)[0].1, "alpha LOCAL gamma");
254 let store = onestore::Store::parse(&server.durable).unwrap();
255 assert!(
256 onestore::RevisionIndex::parse(&store)
257 .unwrap()
258 .spaces
259 .values()
260 .flat_map(|space| space.revisions.values())
261 .all(|revision| revision.encrypted)
262 );
263}
264
265/// Setting a password rewrites the section under new identities that its TOC follows and
266/// drops the plaintext replica; changing and removing it rewrite it again.
267#[test]
268fn a_password_is_set_changed_and_removed_in_a_notebook() {
269 const TEXT: &str = "Kept through every password";
270 let temporary = tempfile::tempdir().unwrap();
271 let folder = temporary.path().join("notebook");
272 let cache = temporary.path().join("cache");
273 let creation = onestore::PageCreation::new(None, Some(TEXT), "Fixture").unwrap();
274 let mut notebook = Notebook::create(&folder, &cache, 0x00f0_a0c0, &creation).unwrap();
275 let path = notebook.catalog().sections[0].path.clone();
276 let plain = notebook.replica_path(&path).unwrap();
277 notebook.section(&path, || {}).unwrap().close().unwrap();
278 assert!(plain.exists());
279 let identity = notebook.catalog().sections[0].file_id;
280
281 let first = notebook
282 .set_password(&path, None, Some("first"))
283 .unwrap()
284 .unwrap();
285 assert!(!plain.exists());
286 let section = &notebook.catalog().sections[0];
287 assert!(matches!(section.state, SectionState::Locked));
288 assert_ne!(section.file_id, identity);
289 assert!(
290 notebook
291 .catalog()
292 .toc
293 .as_ref()
294 .unwrap()
295 .unresolved
296 .is_empty()
297 );
298 let image = notebook.read_section(&path).unwrap();
299 let utf16: Vec<u8> = TEXT.encode_utf16().flat_map(u16::to_le_bytes).collect();
300 assert!(!image.windows(utf16.len()).any(|w| w == utf16));
301 let key = notebook.unlock(&path, "first").unwrap();
302 assert_eq!(key.secret(), first.secret());
303 let session = notebook.section_unlocked(&path, &key, || {}).unwrap();
304 assert_eq!(session.pages().unwrap()[0].1, TEXT);
305 session.close().unwrap();
306 assert!(!leaks(&notebook.replica_path(&path).unwrap(), TEXT));
307
308 assert!(notebook.set_password(&path, None, Some("second")).is_err());
309 let second = notebook
310 .set_password(&path, Some(&key), Some("second"))
311 .unwrap()
312 .unwrap();
313 assert!(notebook.unlock(&path, "first").is_err());
314 let session = notebook.section_unlocked(&path, &second, || {}).unwrap();
315 assert_eq!(session.pages().unwrap()[0].1, TEXT);
316 session.close().unwrap();
317
318 assert!(
319 notebook
320 .set_password(&path, Some(&second), None)
321 .unwrap()
322 .is_none()
323 );
324 let section = &notebook.catalog().sections[0];
325 assert!(matches!(section.state, SectionState::Readable { .. }));
326 let session = notebook.section(&path, || {}).unwrap();
327 assert_eq!(session.pages().unwrap()[0].1, TEXT);
328}
329
330/// A section whose edits wait to be published keeps its password until they are.
331#[test]
332fn a_password_waits_for_queued_edits() {
333 let temporary = tempfile::tempdir().unwrap();
334 let folder = temporary.path().join("notebook");
335 let creation = onestore::PageCreation::new(None, Some("Waiting"), "Fixture").unwrap();
336 let mut notebook = Notebook::create(
337 &folder,
338 temporary.path().join("cache"),
339 0x00f0_a0c0,
340 &creation,
341 )
342 .unwrap();
343 let path = notebook.catalog().sections[0].path.clone();
344 let session = notebook.section(&path, || {}).unwrap();
345 session.set_offline(true);
346 let (space, ..) = session.pages().unwrap()[0].clone();
347 let page = session.page(space).unwrap();
348 let mut edited = page.clone();
349 edited.title = "Waiting still".into();
350 let ops = onestore::op::lower_page(&page, &edited).unwrap();
351 session
352 .apply(
353 "Fixture",
354 Edit {
355 at: 134_000_000_000_000_000,
356 ops: ops.into_iter().map(|op| Op::Page { space, op }).collect(),
357 },
358 )
359 .unwrap();
360 session.written().unwrap();
361 session.close().unwrap();
362 assert!(notebook.set_password(&path, None, Some("early")).is_err());
363 assert!(matches!(
364 notebook.catalog().sections[0].state,
365 SectionState::Readable { .. }
366 ));
367}
368
369/// A notebook `here` makes, also opened `there` as another device opens it, with its first
370/// section's path and the page title it starts with.
371fn two_devices(temporary: &Path, title: &str) -> (Notebook, Notebook, String) {
372 let folder = temporary.join("notebook");
373 let creation = onestore::PageCreation::new(None, Some(title), "Fixture").unwrap();
374 let here = Notebook::create(&folder, temporary.join("here"), 0x00f0_a0c0, &creation).unwrap();
375 let path = here.catalog().sections[0].path.clone();
376 let there = Notebook::open(&folder, temporary.join("there")).unwrap();
377 (here, there, path)
378}
379
380/// Another device's replica of a section, plaintext, holds none of it once the section is
381/// protected here and that device reads the notebook again.
382#[test]
383fn a_replica_drops_its_plaintext_once_its_section_is_protected_elsewhere() {
384 const TITLE: &str = "Read on the other device";
385 let temporary = tempfile::tempdir().unwrap();
386 let (mut here, mut there, path) = two_devices(temporary.path(), TITLE);
387 there.section(&path, || {}).unwrap().close().unwrap();
388 let replica = there.replica_path(&path).unwrap();
389 assert!(leaks(&replica, TITLE));
390 here.set_password(&path, None, Some("secret")).unwrap();
391 there.refresh().unwrap();
392 assert!(!leaks(&replica, TITLE));
393 let again = Notebook::open(
394 temporary.path().join("notebook"),
395 temporary.path().join("there"),
396 );
397 assert!(matches!(
398 again.unwrap().catalog().sections[0].state,
399 SectionState::Locked
400 ));
401 assert!(!leaks(&replica, TITLE));
402}
403
404/// An open section that another device protects stops synchronizing as protected, and its
405/// plaintext goes once it closes.
406#[test]
407fn an_open_section_protected_elsewhere_stops_as_protected() {
408 const TITLE: &str = "Open while protected";
409 let temporary = tempfile::tempdir().unwrap();
410 let (mut here, mut there, path) = two_devices(temporary.path(), TITLE);
411 let session = there.section(&path, || {}).unwrap();
412 let replica = there.replica_path(&path).unwrap();
413 here.set_password(&path, None, Some("secret")).unwrap();
414 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
415 loop {
416 session.wake();
417 let state = session.sync_status().unwrap().state();
418 if state == notebook::session::SyncState::Protected {
419 break;
420 }
421 assert!(std::time::Instant::now() < deadline, "still {state:?}");
422 std::thread::sleep(std::time::Duration::from_millis(50));
423 }
424 session.close().unwrap();
425 there.refresh().unwrap();
426 assert!(!leaks(&replica, TITLE));
427}
428
429/// Edits another device queued before the section was protected wait in its replica until
430/// that device unlocks the section, then publish under the key, the page they changed coming
431/// back as a copy, as a page another client removed does; nothing stays in the clear.
432#[test]
433fn edits_queued_before_a_password_publish_under_it_once_unlocked() {
434 const TITLE: &str = "Protected meanwhile";
435 const TYPED: &str = "Typed offline meanwhile";
436 let temporary = tempfile::tempdir().unwrap();
437 let (mut here, mut there, path) = two_devices(temporary.path(), TITLE);
438 let session = there.section(&path, || {}).unwrap();
439 session.set_offline(true);
440 let (space, ..) = session.pages().unwrap()[0].clone();
441 let page = session.page(space).unwrap();
442 let Some(PageObject::Title(title)) = page.objects.first() else {
443 panic!("no title")
444 };
445 let text = title.outlines[0].paragraphs[0].text().unwrap().id;
446 let end = TITLE.encode_utf16().count() as u32;
447 session
448 .apply("Fixture", replace(space, text, 0..end, TYPED))
449 .unwrap();
450 session.written().unwrap();
451 session.close().unwrap();
452 let held = there.replica_path(&path).unwrap();
453
454 here.set_password(&path, None, Some("secret")).unwrap();
455 there.refresh().unwrap();
456 assert_eq!(Replica::open(&held).unwrap().pending().unwrap().len(), 1);
457
458 let key = there.unlock(&path, "secret").unwrap();
459 assert!(!held.exists());
460 let session = there.section_unlocked(&path, &key, || {}).unwrap();
461 let titles: Vec<String> = session
462 .pages()
463 .unwrap()
464 .into_iter()
465 .map(|(_, title, _)| title)
466 .collect();
467 assert_eq!(titles, [TITLE, TYPED]);
468 published(&session);
469 session.close().unwrap();
470 assert!(!leaks(&there.replica_path(&path).unwrap(), TYPED));
471 let image = there.read_section(&path).unwrap();
472 let utf16: Vec<u8> = TYPED.encode_utf16().flat_map(u16::to_le_bytes).collect();
473 assert!(!image.windows(utf16.len()).any(|w| w == utf16));
474 let stored = notebook::session::stored_pages_unlocked(&image, &key).unwrap();
475 assert!(stored.iter().any(|page| page.page.title == TYPED));
476}
477
478/// Waits until `section` has published every edit.
479fn published(section: &notebook::session::Section) {
480 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
481 section.wake();
482 while !section.pending().unwrap().is_empty() {
483 assert!(
484 std::time::Instant::now() < deadline,
485 "edits were not published"
486 );
487 std::thread::sleep(std::time::Duration::from_millis(20));
488 }
489}
490
491/// Appends `text` to the first paragraph of every page of `section`.
492fn append(section: &notebook::session::Section, text: &str) {
493 for (space, ..) in section.pages().unwrap() {
494 let page = section.page(space).unwrap();
495 let Some((id, current)) = texts(&page).into_iter().next() else {
496 continue;
497 };
498 let end = current.encode_utf16().count() as u32;
499 section
500 .apply("Snowbound Test", replace(space, id, end..end, text))
501 .unwrap();
502 }
503 section.written().unwrap();
504}
505
506/// The native gate's candidate: a notebook whose sections Snowbound protected, edited under
507/// their keys, gave another password, unprotected, and merged into a conflict page, each
508/// from OneNote 2010's pages. `ONESTORE_PROTECTED_EXPORT` names a new directory receiving it
509/// and its passwords for a cold open in OneNote.
510#[test]
511fn a_notebook_protected_through_its_sessions() {
512 let source = std::fs::read(concat!(
513 env!("CARGO_MANIFEST_DIR"),
514 "/../../corpus/protected-sections/source/synthetic.one"
515 ))
516 .unwrap();
517 let pages = notebook::session::stored_pages(&source).unwrap();
518 let temporary = tempfile::tempdir().unwrap();
519 let folder = temporary.path().join("notebook");
520 let cache = temporary.path().join("cache");
521 let creation = onestore::PageCreation::new(None, Some("Gate"), "Snowbound Test").unwrap();
522 let mut notebook = Notebook::create(&folder, &cache, 0x00f0_a0c0, &creation).unwrap();
523 let names = ["Protected", "Edited", "Changed", "Removed", "Conflicted"];
524 for name in names {
525 let path = notebook.create_section("", name, &creation).unwrap();
526 let section = notebook.section(&path, || {}).unwrap();
527 for stored in &pages {
528 section.import_page(&stored.page, "Snowbound Test").unwrap();
529 }
530 published(&section);
531 section.close().unwrap();
532 }
533 let path = |name: &str| format!("{name}.one");
534 let mut passwords = serde_json::Map::new();
535 let mut keys = std::collections::BTreeMap::new();
536 for name in names {
537 let password = format!("{name} gate password");
538 let key = notebook
539 .set_password(&path(name), None, Some(&password))
540 .unwrap()
541 .unwrap();
542 passwords.insert(path(name), password.into());
543 keys.insert(name, key);
544 }
545 // Edited under its key: text on every page, published as dependent revisions.
546 let section = notebook
547 .section_unlocked(&path("Edited"), &keys["Edited"], || {})
548 .unwrap();
549 for round in ["first", "second", "third"] {
550 append(&section, &format!(" Edited {round} under the key."));
551 published(&section);
552 }
553 section.close().unwrap();
554 // Changed: another password.
555 let changed = notebook
556 .set_password(
557 &path("Changed"),
558 Some(&keys["Changed"]),
559 Some("Changed again"),
560 )
561 .unwrap()
562 .unwrap();
563 passwords.insert(path("Changed"), "Changed again".into());
564 let section = notebook
565 .section_unlocked(&path("Changed"), &changed, || {})
566 .unwrap();
567 append(&section, " Written under the changed password.");
568 published(&section);
569 section.close().unwrap();
570 // Removed: no password.
571 assert!(
572 notebook
573 .set_password(&path("Removed"), Some(&keys["Removed"]), None)
574 .unwrap()
575 .is_none()
576 );
577 passwords.insert(path("Removed"), serde_json::Value::Null);
578 // Conflicted: two copies edit one paragraph offline; the second to publish keeps a
579 // conflict page.
580 let second = Notebook::open(&folder, temporary.path().join("second")).unwrap();
581 let key = &keys["Conflicted"];
582 let ours = notebook
583 .section_unlocked(&path("Conflicted"), key, || {})
584 .unwrap();
585 let theirs = second
586 .section_unlocked(&path("Conflicted"), key, || {})
587 .unwrap();
588 for (section, word) in [(&ours, " Ours offline."), (&theirs, " Theirs offline.")] {
589 section.set_offline(true);
590 append(section, word);
591 }
592 ours.set_offline(false);
593 published(&ours);
594 theirs.set_offline(false);
595 published(&theirs);
596 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
597 while theirs.conflicts().unwrap().is_empty() {
598 assert!(std::time::Instant::now() < deadline, "no conflict page");
599 std::thread::sleep(std::time::Duration::from_millis(20));
600 }
601 published(&theirs);
602 ours.close().unwrap();
603 theirs.close().unwrap();
604
605 let notebook = Notebook::open(&folder, temporary.path().join("check")).unwrap();
606 for name in names {
607 let image = notebook.read_section(&path(name)).unwrap();
608 let clear = |word: &str| {
609 let utf16: Vec<u8> = word.encode_utf16().flat_map(u16::to_le_bytes).collect();
610 image.windows(utf16.len()).any(|w| w == utf16)
611 || image.windows(word.len()).any(|w| w == word.as_bytes())
612 };
613 for word in [
614 "Edited first",
615 "Ours offline",
616 "Theirs offline",
617 "changed password",
618 ] {
619 assert!(!clear(word), "{name} holds {word} in the clear");
620 }
621 assert_eq!(clear("positioned outline"), name == "Removed", "{name}");
622 let state = &notebook
623 .catalog()
624 .sections
625 .iter()
626 .find(|section| section.path == path(name))
627 .unwrap()
628 .state;
629 assert_eq!(
630 matches!(state, SectionState::Locked),
631 name != "Removed",
632 "{name}"
633 );
634 }
635 assert!(
636 notebook
637 .catalog()
638 .toc
639 .as_ref()
640 .unwrap()
641 .unresolved
642 .is_empty()
643 );
644 let key = notebook
645 .unlock(&path("Conflicted"), "Conflicted gate password")
646 .unwrap();
647 let stored = Section::unlock(
648 &Arena::default(),
649 notebook.read_section(&path("Conflicted")).unwrap(),
650 &key,
651 )
652 .unwrap()
653 .conflicts()
654 .unwrap();
655 // Each page's first paragraph clashed, so each keeps a conflict page.
656 assert_eq!(stored.len(), 2);
657 if let Some(export) = std::env::var_os("ONESTORE_PROTECTED_EXPORT") {
658 let export = Path::new(&export);
659 copy(&folder, &export.join("notebook"));
660 std::fs::write(
661 export.join("passwords.json"),
662 serde_json::to_vec_pretty(&passwords).unwrap(),
663 )
664 .unwrap();
665 }
666}