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, false, || {}).is_err());
111 let key = notebook.unlock(&section.path, &password).unwrap();
112 let session = notebook
113 .section_unlocked(&section.path, &key, false, || {})
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
278 .section(&path, false, || {})
279 .unwrap()
280 .close()
281 .unwrap();
282 assert!(plain.exists());
283 let identity = notebook.catalog().sections[0].file_id;
284
285 let first = notebook
286 .set_password(&path, None, Some("first"))
287 .unwrap()
288 .unwrap();
289 assert!(!plain.exists());
290 let section = &notebook.catalog().sections[0];
291 assert!(matches!(section.state, SectionState::Locked));
292 assert_ne!(section.file_id, identity);
293 assert!(
294 notebook
295 .catalog()
296 .toc
297 .as_ref()
298 .unwrap()
299 .unresolved
300 .is_empty()
301 );
302 let image = notebook.read_section(&path).unwrap();
303 let utf16: Vec<u8> = TEXT.encode_utf16().flat_map(u16::to_le_bytes).collect();
304 assert!(!image.windows(utf16.len()).any(|w| w == utf16));
305 let key = notebook.unlock(&path, "first").unwrap();
306 assert_eq!(key.secret(), first.secret());
307 let session = notebook
308 .section_unlocked(&path, &key, false, || {})
309 .unwrap();
310 assert_eq!(session.pages().unwrap()[0].1, TEXT);
311 session.close().unwrap();
312 assert!(!leaks(&notebook.replica_path(&path).unwrap(), TEXT));
313
314 assert!(notebook.set_password(&path, None, Some("second")).is_err());
315 let second = notebook
316 .set_password(&path, Some(&key), Some("second"))
317 .unwrap()
318 .unwrap();
319 assert!(notebook.unlock(&path, "first").is_err());
320 let session = notebook
321 .section_unlocked(&path, &second, false, || {})
322 .unwrap();
323 assert_eq!(session.pages().unwrap()[0].1, TEXT);
324 session.close().unwrap();
325
326 assert!(
327 notebook
328 .set_password(&path, Some(&second), None)
329 .unwrap()
330 .is_none()
331 );
332 let section = &notebook.catalog().sections[0];
333 assert!(matches!(section.state, SectionState::Readable { .. }));
334 let session = notebook.section(&path, false, || {}).unwrap();
335 assert_eq!(session.pages().unwrap()[0].1, TEXT);
336}
337
338/// A section whose edits wait to be published keeps its password until they are.
339#[test]
340fn a_password_waits_for_queued_edits() {
341 let temporary = tempfile::tempdir().unwrap();
342 let folder = temporary.path().join("notebook");
343 let creation = onestore::PageCreation::new(None, Some("Waiting"), "Fixture").unwrap();
344 let mut notebook = Notebook::create(
345 &folder,
346 temporary.path().join("cache"),
347 0x00f0_a0c0,
348 &creation,
349 )
350 .unwrap();
351 let path = notebook.catalog().sections[0].path.clone();
352 let session = notebook.section(&path, false, || {}).unwrap();
353 session.set_offline(true);
354 let (space, ..) = session.pages().unwrap()[0].clone();
355 let page = session.page(space).unwrap();
356 let mut edited = page.clone();
357 edited.title = "Waiting still".into();
358 let ops = onestore::op::lower_page(&page, &edited).unwrap();
359 session
360 .apply(
361 "Fixture",
362 Edit {
363 at: 134_000_000_000_000_000,
364 ops: ops.into_iter().map(|op| Op::Page { space, op }).collect(),
365 },
366 )
367 .unwrap();
368 session.written().unwrap();
369 session.close().unwrap();
370 assert!(notebook.set_password(&path, None, Some("early")).is_err());
371 assert!(matches!(
372 notebook.catalog().sections[0].state,
373 SectionState::Readable { .. }
374 ));
375}
376
377/// A notebook `here` makes, also opened `there` as another device opens it, with its first
378/// section's path and the page title it starts with.
379fn two_devices(temporary: &Path, title: &str) -> (Notebook, Notebook, String) {
380 let folder = temporary.join("notebook");
381 let creation = onestore::PageCreation::new(None, Some(title), "Fixture").unwrap();
382 let here = Notebook::create(&folder, temporary.join("here"), 0x00f0_a0c0, &creation).unwrap();
383 let path = here.catalog().sections[0].path.clone();
384 let there = Notebook::open(&folder, temporary.join("there")).unwrap();
385 (here, there, path)
386}
387
388/// Another device's replica of a section, plaintext, holds none of it once the section is
389/// protected here and that device reads the notebook again.
390#[test]
391fn a_replica_drops_its_plaintext_once_its_section_is_protected_elsewhere() {
392 const TITLE: &str = "Read on the other device";
393 let temporary = tempfile::tempdir().unwrap();
394 let (mut here, mut there, path) = two_devices(temporary.path(), TITLE);
395 there.section(&path, false, || {}).unwrap().close().unwrap();
396 let replica = there.replica_path(&path).unwrap();
397 assert!(leaks(&replica, TITLE));
398 here.set_password(&path, None, Some("secret")).unwrap();
399 there.refresh().unwrap();
400 assert!(!leaks(&replica, TITLE));
401 let again = Notebook::open(
402 temporary.path().join("notebook"),
403 temporary.path().join("there"),
404 );
405 assert!(matches!(
406 again.unwrap().catalog().sections[0].state,
407 SectionState::Locked
408 ));
409 assert!(!leaks(&replica, TITLE));
410}
411
412/// An open section that another device protects stops synchronizing as protected, and its
413/// plaintext goes once it closes.
414#[test]
415fn an_open_section_protected_elsewhere_stops_as_protected() {
416 const TITLE: &str = "Open while protected";
417 let temporary = tempfile::tempdir().unwrap();
418 let (mut here, mut there, path) = two_devices(temporary.path(), TITLE);
419 let session = there.section(&path, false, || {}).unwrap();
420 let replica = there.replica_path(&path).unwrap();
421 here.set_password(&path, None, Some("secret")).unwrap();
422 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
423 loop {
424 session.wake();
425 let state = session.sync_status().unwrap().state();
426 if state == notebook::session::SyncState::Protected {
427 break;
428 }
429 assert!(std::time::Instant::now() < deadline, "still {state:?}");
430 std::thread::sleep(std::time::Duration::from_millis(50));
431 }
432 session.close().unwrap();
433 there.refresh().unwrap();
434 assert!(!leaks(&replica, TITLE));
435}
436
437/// Edits another device queued before the section was protected wait in its replica until
438/// that device unlocks the section, then publish under the key, the page they changed coming
439/// back as a copy, as a page another client removed does; nothing stays in the clear.
440#[test]
441fn edits_queued_before_a_password_publish_under_it_once_unlocked() {
442 const TITLE: &str = "Protected meanwhile";
443 const TYPED: &str = "Typed offline meanwhile";
444 let temporary = tempfile::tempdir().unwrap();
445 let (mut here, mut there, path) = two_devices(temporary.path(), TITLE);
446 let session = there.section(&path, false, || {}).unwrap();
447 session.set_offline(true);
448 let (space, ..) = session.pages().unwrap()[0].clone();
449 let page = session.page(space).unwrap();
450 let Some(PageObject::Title(title)) = page.objects.first() else {
451 panic!("no title")
452 };
453 let text = title.outlines[0].paragraphs[0].text().unwrap().id;
454 let end = TITLE.encode_utf16().count() as u32;
455 session
456 .apply("Fixture", replace(space, text, 0..end, TYPED))
457 .unwrap();
458 session.written().unwrap();
459 session.close().unwrap();
460 let held = there.replica_path(&path).unwrap();
461
462 here.set_password(&path, None, Some("secret")).unwrap();
463 there.refresh().unwrap();
464 assert_eq!(Replica::open(&held).unwrap().pending().unwrap().len(), 1);
465
466 let key = there.unlock(&path, "secret").unwrap();
467 assert!(!held.exists());
468 let session = there.section_unlocked(&path, &key, false, || {}).unwrap();
469 let titles: Vec<String> = session
470 .pages()
471 .unwrap()
472 .into_iter()
473 .map(|(_, title, _)| title)
474 .collect();
475 assert_eq!(titles, [TITLE, TYPED]);
476 published(&session);
477 session.close().unwrap();
478 assert!(!leaks(&there.replica_path(&path).unwrap(), TYPED));
479 let image = there.read_section(&path).unwrap();
480 let utf16: Vec<u8> = TYPED.encode_utf16().flat_map(u16::to_le_bytes).collect();
481 assert!(!image.windows(utf16.len()).any(|w| w == utf16));
482 let stored = notebook::session::stored_pages_unlocked(&image, &key).unwrap();
483 assert!(stored.iter().any(|page| page.page.title == TYPED));
484}
485
486/// Waits until `section` has published every edit.
487fn published(section: &notebook::session::Section) {
488 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
489 section.wake();
490 while !section.pending().unwrap().is_empty() {
491 assert!(
492 std::time::Instant::now() < deadline,
493 "edits were not published"
494 );
495 std::thread::sleep(std::time::Duration::from_millis(20));
496 }
497}
498
499/// Appends `text` to the first paragraph of every page of `section`.
500fn append(section: &notebook::session::Section, text: &str) {
501 for (space, ..) in section.pages().unwrap() {
502 let page = section.page(space).unwrap();
503 let Some((id, current)) = texts(&page).into_iter().next() else {
504 continue;
505 };
506 let end = current.encode_utf16().count() as u32;
507 section
508 .apply("Snowbound Test", replace(space, id, end..end, text))
509 .unwrap();
510 }
511 section.written().unwrap();
512}
513
514/// The native gate's candidate: a notebook whose sections Snowbound protected, edited under
515/// their keys, gave another password, unprotected, and merged into a conflict page, each
516/// from OneNote 2010's pages. `ONESTORE_PROTECTED_EXPORT` names a new directory receiving it
517/// and its passwords for a cold open in OneNote.
518#[test]
519fn a_notebook_protected_through_its_sessions() {
520 let source = std::fs::read(concat!(
521 env!("CARGO_MANIFEST_DIR"),
522 "/../../corpus/protected-sections/source/synthetic.one"
523 ))
524 .unwrap();
525 let pages = notebook::session::stored_pages(&source).unwrap();
526 let temporary = tempfile::tempdir().unwrap();
527 let folder = temporary.path().join("notebook");
528 let cache = temporary.path().join("cache");
529 let creation = onestore::PageCreation::new(None, Some("Gate"), "Snowbound Test").unwrap();
530 let mut notebook = Notebook::create(&folder, &cache, 0x00f0_a0c0, &creation).unwrap();
531 let names = ["Protected", "Edited", "Changed", "Removed", "Conflicted"];
532 for name in names {
533 let path = notebook.create_section("", name, &creation).unwrap();
534 let section = notebook.section(&path, false, || {}).unwrap();
535 for stored in &pages {
536 section.import_page(&stored.page, "Snowbound Test").unwrap();
537 }
538 published(&section);
539 section.close().unwrap();
540 }
541 let path = |name: &str| format!("{name}.one");
542 let mut passwords = serde_json::Map::new();
543 let mut keys = std::collections::BTreeMap::new();
544 for name in names {
545 let password = format!("{name} gate password");
546 let key = notebook
547 .set_password(&path(name), None, Some(&password))
548 .unwrap()
549 .unwrap();
550 passwords.insert(path(name), password.into());
551 keys.insert(name, key);
552 }
553 // Edited under its key: text on every page, published as dependent revisions.
554 let section = notebook
555 .section_unlocked(&path("Edited"), &keys["Edited"], false, || {})
556 .unwrap();
557 for round in ["first", "second", "third"] {
558 append(&section, &format!(" Edited {round} under the key."));
559 published(&section);
560 }
561 section.close().unwrap();
562 // Changed: another password.
563 let changed = notebook
564 .set_password(
565 &path("Changed"),
566 Some(&keys["Changed"]),
567 Some("Changed again"),
568 )
569 .unwrap()
570 .unwrap();
571 passwords.insert(path("Changed"), "Changed again".into());
572 let section = notebook
573 .section_unlocked(&path("Changed"), &changed, false, || {})
574 .unwrap();
575 append(&section, " Written under the changed password.");
576 published(&section);
577 section.close().unwrap();
578 // Removed: no password.
579 assert!(
580 notebook
581 .set_password(&path("Removed"), Some(&keys["Removed"]), None)
582 .unwrap()
583 .is_none()
584 );
585 passwords.insert(path("Removed"), serde_json::Value::Null);
586 // Conflicted: two copies edit one paragraph offline; the second to publish keeps a
587 // conflict page.
588 let second = Notebook::open(&folder, temporary.path().join("second")).unwrap();
589 let key = &keys["Conflicted"];
590 let ours = notebook
591 .section_unlocked(&path("Conflicted"), key, false, || {})
592 .unwrap();
593 let theirs = second
594 .section_unlocked(&path("Conflicted"), key, false, || {})
595 .unwrap();
596 for (section, word) in [(&ours, " Ours offline."), (&theirs, " Theirs offline.")] {
597 section.set_offline(true);
598 append(section, word);
599 }
600 ours.set_offline(false);
601 published(&ours);
602 theirs.set_offline(false);
603 published(&theirs);
604 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
605 while theirs.conflicts().unwrap().is_empty() {
606 assert!(std::time::Instant::now() < deadline, "no conflict page");
607 std::thread::sleep(std::time::Duration::from_millis(20));
608 }
609 published(&theirs);
610 ours.close().unwrap();
611 theirs.close().unwrap();
612
613 let notebook = Notebook::open(&folder, temporary.path().join("check")).unwrap();
614 for name in names {
615 let image = notebook.read_section(&path(name)).unwrap();
616 let clear = |word: &str| {
617 let utf16: Vec<u8> = word.encode_utf16().flat_map(u16::to_le_bytes).collect();
618 image.windows(utf16.len()).any(|w| w == utf16)
619 || image.windows(word.len()).any(|w| w == word.as_bytes())
620 };
621 for word in [
622 "Edited first",
623 "Ours offline",
624 "Theirs offline",
625 "changed password",
626 ] {
627 assert!(!clear(word), "{name} holds {word} in the clear");
628 }
629 assert_eq!(clear("positioned outline"), name == "Removed", "{name}");
630 let state = &notebook
631 .catalog()
632 .sections
633 .iter()
634 .find(|section| section.path == path(name))
635 .unwrap()
636 .state;
637 assert_eq!(
638 matches!(state, SectionState::Locked),
639 name != "Removed",
640 "{name}"
641 );
642 }
643 assert!(
644 notebook
645 .catalog()
646 .toc
647 .as_ref()
648 .unwrap()
649 .unresolved
650 .is_empty()
651 );
652 let key = notebook
653 .unlock(&path("Conflicted"), "Conflicted gate password")
654 .unwrap();
655 let stored = Section::unlock(
656 &Arena::default(),
657 notebook.read_section(&path("Conflicted")).unwrap(),
658 &key,
659 )
660 .unwrap()
661 .conflicts()
662 .unwrap();
663 // Each page's first paragraph clashed, so each keeps a conflict page.
664 assert_eq!(stored.len(), 2);
665 if let Some(export) = std::env::var_os("ONESTORE_PROTECTED_EXPORT") {
666 let export = Path::new(&export);
667 copy(&folder, &export.join("notebook"));
668 std::fs::write(
669 export.join("passwords.json"),
670 serde_json::to_vec_pretty(&passwords).unwrap(),
671 )
672 .unwrap();
673 }
674}