| 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 | |
| 6 | use notebook::{ |
| 7 | live::share::{self, Refusal, Sharing}, |
| 8 | session::{Notebook, SyncState}, |
| 9 | }; |
| 10 | use onestore::Arena; |
| 11 | use std::sync::Arc; |
| 12 | |
| 13 | #[path = "support/live.rs"] |
| 14 | mod live; |
| 15 | use 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] |
| 20 | fn 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] |
| 59 | fn 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] |
| 99 | fn 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] |
| 147 | fn 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] |
| 196 | fn 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] |
| 274 | fn 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] |
| 311 | fn 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] |
| 391 | fn 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 | } |