1//! Live Share: guests open a notebook a host serves through a relay, edit it through the
2//! replica and queue they use on a share, queue while the host is away, conflict as on a
3//! share, pass protected sections through as ciphertext, and are refused with a wrong code.
4#![cfg(feature = "live")]
5
6use notebook::{
7 live::share::{self, Refusal, Sharing},
8 session::{Notebook, SyncState},
9};
10use onestore::Arena;
11use std::sync::Arc;
12
13#[path = "support/live.rs"]
14mod live;
15use live::*;
16
17/// A guest opens a section through the host, its edit lands in the host's file, and the
18/// host's own edit reaches the guest.
19#[test]
20fn a_guest_edits_the_host_s_notebook() {
21 let directory = tempfile::tempdir().unwrap();
22 let folder = notebook(directory.path());
23 let url = relay(Default::default());
24 let sharing = Sharing::new("").unwrap();
25 let host = host(&folder, &directory.path().join("host"), &sharing, &url);
26 let (guest, notebook) = guest("Grace", &code(&host), &url, &directory.path().join("grace"));
27 let paths: Vec<String> = notebook
28 .catalog()
29 .sections
30 .iter()
31 .map(|section| section.path.clone())
32 .collect();
33 assert_eq!(paths, ["Garden.one", "Sealed.one"]);
34 let section = open(&notebook, &guest, "Garden.one", None);
35 let file = folder.join("Garden.one");
36 let id = replace(&section, &std::fs::read(&file).unwrap(), 0..8, "Grace's");
37 published(&section, id);
38 assert_eq!(
39 server::text(&std::fs::read(&file).unwrap()).2,
40 "Grace's text"
41 );
42 assert_eq!(host.guests().len(), 1);
43
44 // The host's own edit, as its app commits one.
45 let image = std::fs::read(&file).unwrap();
46 let (space, text, _) = server::text(&image);
47 std::fs::write(&file, server::typed(&image, space, text, 0..7, "Ada's")).unwrap();
48 host.touched(&["Garden.one".into()]);
49 until("the guest never saw the host's edit", || {
50 section
51 .page(space)
52 .is_ok_and(|page| server::page_texts(&page).contains(&"Ada's text".to_owned()))
53 });
54}
55
56/// While the host is away a guest's edits wait in its replica, the notebook opens from its
57/// last listing, and once the host is back the edits reach its file.
58#[test]
59fn a_guest_queues_while_the_host_is_away() {
60 let directory = tempfile::tempdir().unwrap();
61 let folder = notebook(directory.path());
62 let url = relay(Default::default());
63 let sharing = Sharing::new("").unwrap();
64 let host_cache = directory.path().join("host");
65 let host = host(&folder, &host_cache, &sharing, &url);
66 let cache = directory.path().join("grace");
67 let (guest, notebook) = guest("Grace", &code(&host), &url, &cache);
68 let section = open(&notebook, &guest, "Garden.one", None);
69 let file = folder.join("Garden.one");
70 let image = std::fs::read(&file).unwrap();
71
72 // Ada's computer goes to sleep.
73 let sharing = host.sharing();
74 drop(host);
75 until("the host never left", || guest.host().is_none());
76 let id = replace(&section, &image, 0..8, "Offline");
77 until("the section never said the host was away", || {
78 section
79 .sync_status()
80 .is_ok_and(|status| status.state() == SyncState::NotConnected && status.queued > 0)
81 });
82 assert_eq!(std::fs::read(&file).unwrap(), image);
83 // The notebook opens from its last listing while the host is away.
84 let reopened = Notebook::open_hosted(Arc::clone(&guest), &cache).unwrap();
85 assert_eq!(reopened.catalog().sections.len(), 2);
86
87 let host = self::host(&folder, &host_cache, &sharing, &url);
88 published(&section, id);
89 assert_eq!(
90 server::text(&std::fs::read(&file).unwrap()).2,
91 "Offline text"
92 );
93 drop(host);
94}
95
96/// Two guests that change the same words while apart: the second to publish gets OneNote's
97/// conflict page, in its replica and in the host's file.
98#[test]
99fn two_guests_on_one_page_conflict_as_on_a_share() {
100 let directory = tempfile::tempdir().unwrap();
101 let folder = notebook(directory.path());
102 let url = relay(Default::default());
103 let sharing = Sharing::new("").unwrap();
104 let host_cache = directory.path().join("host");
105 let host = host(&folder, &host_cache, &sharing, &url);
106 let code = code(&host);
107 let (grace, grace_notebook) = guest("Grace", &code, &url, &directory.path().join("grace"));
108 let (alan, alan_notebook) = guest("Alan", &code, &url, &directory.path().join("alan"));
109 let graces = open(&grace_notebook, &grace, "Garden.one", None);
110 let alans = open(&alan_notebook, &alan, "Garden.one", None);
111 let file = folder.join("Garden.one");
112 let image = std::fs::read(&file).unwrap();
113
114 let sharing = host.sharing();
115 drop(host);
116 until("the host never left", || {
117 grace.host().is_none() && alan.host().is_none()
118 });
119 let first = replace(&graces, &image, 0..8, "Grace's");
120 let second = replace(&alans, &image, 0..8, "Alan's");
121 let host = self::host(&folder, &host_cache, &sharing, &url);
122 published(&graces, first);
123 published(&alans, second);
124 let stored = std::fs::read(&file).unwrap();
125 let conflicts = server::conflicts(&stored);
126 assert_eq!(conflicts.len(), 1, "one page holds a conflict page");
127 let (user, kept) = &conflicts[0].1[0];
128 assert_eq!(user, "Guest");
129 let texts: Vec<String> = server::pages(&stored)
130 .iter()
131 .flat_map(|(_, page)| server::page_texts(page))
132 .chain(kept.iter().cloned())
133 .collect();
134 assert!(
135 texts.contains(&"Grace's text".to_owned()) && texts.contains(&"Alan's text".to_owned()),
136 "{texts:?}"
137 );
138 until("the host's conflict never reached a guest", || {
139 !alans.conflicts().unwrap().is_empty() || !graces.conflicts().unwrap().is_empty()
140 });
141 drop(host);
142}
143
144/// A protected section's guest unlocks it with the password; the host only ever stores
145/// what the guest sealed.
146#[test]
147fn a_protected_section_passes_through_as_ciphertext() {
148 let directory = tempfile::tempdir().unwrap();
149 let folder = notebook(directory.path());
150 let url = relay(Default::default());
151 let sharing = Sharing::new("").unwrap();
152 let host = host(&folder, &directory.path().join("host"), &sharing, &url);
153 let (guest, notebook) = guest("Grace", &code(&host), &url, &directory.path().join("grace"));
154 assert!(notebook.unlock("Sealed.one", "wrong password").is_err());
155 let key = notebook.unlock("Sealed.one", PASSWORD).unwrap();
156 let section = open(&notebook, &guest, "Sealed.one", Some(&key));
157 let file = folder.join("Sealed.one");
158 let arena = Arena::default();
159 let mut unlocked =
160 onestore::Section::unlock(&arena, std::fs::read(&file).unwrap(), &key).unwrap();
161 let (space, ..) = unlocked.pages().unwrap()[0];
162 let page = unlocked.page(space).unwrap();
163 let text = page
164 .objects
165 .iter()
166 .find_map(|object| match object {
167 onestore::page::PageObject::Outline(outline) => outline
168 .paragraphs
169 .iter()
170 .find_map(|p| p.text().map(|t| t.id)),
171 _ => None,
172 })
173 .unwrap();
174 let id = replaced(&section, space, text, 0..6, "Guarded");
175 published(&section, id);
176 let stored = std::fs::read(&file).unwrap();
177 let marker = "Guarded"
178 .encode_utf16()
179 .flat_map(u16::to_le_bytes)
180 .collect::<Vec<u8>>();
181 assert!(
182 !stored.windows(marker.len()).any(|window| window == marker),
183 "the host's file holds the new text in the clear"
184 );
185 assert!(onestore::Section::open(&Arena::default(), stored.clone()).is_err());
186 let arena = Arena::default();
187 let reread = onestore::Section::unlock(&arena, stored, &key).unwrap();
188 let texts = server::page_texts(&reread.page(space).unwrap());
189 assert!(texts.contains(&"Guarded text".to_owned()), "{texts:?}");
190}
191
192/// A wrong code is refused without the guest learning anything; the host's code burns after
193/// too many wrong tries and is replaced by new words; too many wrong codes from one network
194/// lock it out; and a guest can't reach outside the notebook or presence's secret.
195#[test]
196fn wrong_codes_are_refused_and_counted() {
197 let directory = tempfile::tempdir().unwrap();
198 let folder = notebook(directory.path());
199 let url = relay(relay::server::Config {
200 burn_after: 2,
201 failures_per_minute: 3,
202 ..Default::default()
203 });
204 let sharing = Sharing::new("").unwrap();
205 let host = host(&folder, &directory.path().join("host"), &sharing, &url);
206 let code = code(&host);
207 let (number, secret) = notebook::live::code::parse(&code).unwrap();
208 let wrong = notebook::live::code::format(number, &mistaken(&secret)).unwrap();
209 let join = |code: &str| share::join(hello("Mallory"), code, "", None, Some(&url));
210 assert_eq!(join("not a code").unwrap_err(), Refusal::Malformed);
211 // A symbol mistyped fails its check here, spending none of the two tries before a burn.
212 let typo = format!(
213 "{}{}",
214 if code.starts_with('7') { '8' } else { '7' },
215 &code[1..]
216 );
217 assert_eq!(join(&typo).unwrap_err(), Refusal::Malformed);
218 assert_eq!(
219 join(&code.to_lowercase().replace('-', " ")).map(|_| ()),
220 Ok(())
221 );
222 assert_eq!(join(&wrong).unwrap_err(), Refusal::Wrong);
223 assert_eq!(join(&wrong).unwrap_err(), Refusal::Wrong);
224 // The code burned, and the host shares a new secret, under a number of its own.
225 until("the code never changed", || {
226 host.code()
227 .is_some_and(|now| now != code && share::code(&now).is_some())
228 });
229 let fresh = host.code().unwrap();
230 assert!(matches!(
231 join(&code).unwrap_err(),
232 Refusal::Expired | Refusal::NoOne
233 ));
234 let (number, secret) = notebook::live::code::parse(&fresh).unwrap();
235 let wrong = notebook::live::code::format(number, &mistaken(&secret)).unwrap();
236 assert_eq!(join(&wrong).unwrap_err(), Refusal::Wrong);
237 // A third wrong code in a minute locks this network out, even from the right code.
238 assert!(matches!(
239 join(&fresh).unwrap_err(),
240 Refusal::TooMany(Some(_))
241 ));
242
243 // Joined, a guest still can't reach outside the notebook.
244 std::fs::create_dir_all(folder.join(".snowbound")).unwrap();
245 std::fs::write(folder.join(".snowbound/live.json"), b"{}").unwrap();
246 std::fs::write(directory.path().join("secret.txt"), b"secret").unwrap();
247 let url = relay(Default::default());
248 let host = self::host(&folder, &directory.path().join("host"), &sharing, &url);
249 let (_guest, notebook) = guest(
250 "Grace",
251 &self::code(&host),
252 &url,
253 &directory.path().join("g"),
254 );
255 let storage = notebook.into_storage();
256 for path in [
257 "../secret.txt",
258 ".snowbound/live.json",
259 "/etc/hosts",
260 "a//b",
261 ] {
262 let error = storage.read_file(path, 1024).unwrap_err();
263 assert!(
264 error.to_string().contains("Outside the notebook"),
265 "{path}: {error}"
266 );
267 }
268 assert!(storage.rename_root("Elsewhere", &[]).is_err());
269}
270
271/// Files larger than one message go up and come back a chunk at a time, through a relay that
272/// hangs up on a peer with more than its queue waiting.
273#[test]
274fn large_files_travel_in_chunks() {
275 let directory = tempfile::tempdir().unwrap();
276 let folder = notebook(directory.path());
277 let url = relay(relay::server::Config {
278 queue: 400 << 10,
279 ..Default::default()
280 });
281 let sharing = Sharing::new("").unwrap();
282 let host = host(&folder, &directory.path().join("host"), &sharing, &url);
283 let (_guest, notebook) = guest("Grace", &code(&host), &url, &directory.path().join("grace"));
284 let storage = notebook.into_storage();
285 let bytes: Vec<u8> = (0..3_000_000u32).map(|at| (at * 7 % 251) as u8).collect();
286 storage
287 .create("Garden_onefiles/big.bin", &bytes)
288 .unwrap_err();
289 std::fs::create_dir(folder.join("Garden_onefiles")).unwrap();
290 storage.create("Garden_onefiles/big.bin", &bytes).unwrap();
291 assert_eq!(
292 std::fs::read(folder.join("Garden_onefiles/big.bin")).unwrap(),
293 bytes
294 );
295 assert_eq!(
296 storage
297 .read_file("Garden_onefiles/big.bin", 4 << 20)
298 .unwrap(),
299 bytes
300 );
301 assert!(
302 storage
303 .read_file("Garden_onefiles/big.bin", 1 << 20)
304 .is_err()
305 );
306}
307
308/// A guest that floods its host with requests is hung up on once too many wait, having had
309/// answers to few of them, and the host goes on serving the others.
310#[test]
311fn a_flooding_guest_is_hung_up_on() {
312 use notebook::live::{
313 Event, Live, Room,
314 wire::{Bye, Request, kind},
315 };
316 use std::sync::{
317 Mutex,
318 atomic::{AtomicUsize, Ordering},
319 };
320 let directory = tempfile::tempdir().unwrap();
321 let folder = notebook(directory.path());
322 let url = relay(Default::default());
323 let sharing = Sharing::new("").unwrap();
324 let host = host(&folder, &directory.path().join("host"), &sharing, &url);
325 let code = code(&host);
326 let (grace, notebook) = guest("Grace", &code, &url, &directory.path().join("grace"));
327 let welcome = share::join(hello("Mallory"), &code, "", None, Some(&url)).unwrap();
328 let replies = Arc::new(AtomicUsize::new(0));
329 let bye = Arc::new(Mutex::new(None));
330 let (counted, said) = (Arc::clone(&replies), Arc::clone(&bye));
331 let mallory = Live::start(
332 hello("Mallory"),
333 &Room::Notebook(welcome.secret),
334 None,
335 Some(&url),
336 move |event| match event {
337 Event::Frame {
338 kind: kind::REPLY, ..
339 } => {
340 counted.fetch_add(1, Ordering::Relaxed);
341 }
342 Event::Frame {
343 kind: kind::BYE,
344 body,
345 ..
346 } => *said.lock().unwrap() = minicbor::decode::<Bye>(body).ok(),
347 _ => {}
348 },
349 )
350 .unwrap();
351 // The line to the host, while Mallory's stream to it is open.
352 let served = |live: &Live| {
353 let host = live
354 .peers()
355 .into_iter()
356 .find(|peer| peer.hello.serves == Some(welcome.share))?;
357 live.line(&host.hello.peer)
358 };
359 until("Mallory never met the host", || served(&mallory).is_some());
360 let line = served(&mallory).unwrap();
361 const SENT: u64 = 5000;
362 for id in 0..SENT {
363 let request = Request {
364 id,
365 path: "Garden.one".into(),
366 ..Request::default()
367 };
368 if line.send(kind::STAMP, &request).is_err() {
369 break;
370 }
371 }
372 until("the host never hung up on Mallory", || {
373 bye.lock().unwrap().is_some() && served(&mallory).is_none()
374 });
375 assert_eq!(bye.lock().unwrap().as_ref().unwrap().reason, "flooded");
376 let answered = replies.load(Ordering::Relaxed);
377 // At most the burst a guest may start at once, what waits for the workers, and what the
378 // rate refills while the flood arrives.
379 assert!(answered < 300, "Mallory had {answered} answers of {SENT}");
380 // Grace, asking at her own pace, is served as before.
381 assert!(grace.host().is_some());
382 assert_eq!(
383 notebook.read_section("Garden.one").unwrap(),
384 std::fs::read(folder.join("Garden.one")).unwrap()
385 );
386}
387
388/// Stopping a share lets every guest go, saying so, and leaves its code and secret for no one:
389/// the code no longer opens anything, and sharing again makes new ones.
390#[test]
391fn stopping_lets_every_guest_go_and_retires_the_code() {
392 let directory = tempfile::tempdir().unwrap();
393 let folder = notebook(directory.path());
394 let url = relay(Default::default());
395 let sharing = Sharing::new("").unwrap();
396 let host = host(&folder, &directory.path().join("host"), &sharing, &url);
397 let code = code(&host);
398 let (guest, _notebook) = guest("Grace", &code, &url, &directory.path().join("grace"));
399 host.stop();
400 until("the guest never heard the host stop", || {
401 guest.ended() == Some(share::Ended::Stopped) && guest.host().is_none()
402 });
403 assert!(host.code().is_none() && host.guests().is_empty());
404 assert!(matches!(
405 share::join(hello("Alan"), &code, "", None, Some(&url)).unwrap_err(),
406 Refusal::NoOne | Refusal::TimedOut
407 ));
408 let again = Sharing::new("").unwrap();
409 assert!(again.secret != sharing.secret && again.share != sharing.share);
410 assert_ne!(again.code, sharing.code);
411}