| 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 | #[test] |
| 57 | fn presence_reconnect_reconciles_missed_changes_without_reconnecting_access() { |
| 58 | use notebook::{ |
| 59 | Remote, |
| 60 | live::{ |
| 61 | Caret, Presence, Spot, |
| 62 | share::{Guest, HostedRemote}, |
| 63 | }, |
| 64 | session::Background, |
| 65 | }; |
| 66 | use onestore::Stamp; |
| 67 | use std::sync::atomic::{AtomicUsize, Ordering}; |
| 68 | |
| 69 | let directory = tempfile::tempdir().unwrap(); |
| 70 | let folder = notebook(directory.path()); |
| 71 | let url = relay(Default::default()); |
| 72 | let sharing = Sharing::new("").unwrap(); |
| 73 | let host = host(&folder, &directory.path().join("host"), &sharing, &url); |
| 74 | let welcome = share::join(hello("Grace"), &code(&host), "", None, Some(&url)).unwrap(); |
| 75 | let relay = PresenceRelay::new(&url, &welcome.room); |
| 76 | let guest = Guest::start( |
| 77 | hello("Grace"), |
| 78 | welcome.share, |
| 79 | welcome.secret, |
| 80 | None, |
| 81 | Some(&relay.url), |
| 82 | || {}, |
| 83 | ) |
| 84 | .unwrap(); |
| 85 | until("the access host was never met", || guest.host().is_some()); |
| 86 | let host_id = guest.host().unwrap().peer; |
| 87 | until("the presence host was never met", || { |
| 88 | guest.peers().iter().any(|peer| peer.hello.peer == host_id) |
| 89 | }); |
| 90 | let mut notebook = |
| 91 | Notebook::open_hosted(Arc::clone(&guest), directory.path().join("grace")).unwrap(); |
| 92 | let section = open(&notebook, &guest, "Garden.one", None); |
| 93 | let background = Background::hosted(Arc::clone(&guest), || {}).unwrap(); |
| 94 | background.watch(notebook.replicas()); |
| 95 | background.hold("Garden.one", &section); |
| 96 | let storage = notebook.into_storage(); |
| 97 | until("the section never synced", || { |
| 98 | section.sync_status().unwrap().synced.is_some() |
| 99 | }); |
| 100 | let touched = Arc::new(AtomicUsize::new(0)); |
| 101 | let heard = Arc::clone(&touched); |
| 102 | let observed = Arc::clone(&guest); |
| 103 | background.on_touched(Some(Box::new(move |_| { |
| 104 | observed.set_presence(Presence::default()); |
| 105 | HostedRemote::new(&observed, "Garden.one").stamp().unwrap(); |
| 106 | heard.fetch_add(1, Ordering::SeqCst); |
| 107 | }))); |
| 108 | let file = folder.join("Garden.one"); |
| 109 | let image = std::fs::read(&file).unwrap(); |
| 110 | let (space, text, _) = server::text(&image); |
| 111 | let stamp = Stamp::of(&image).unwrap(); |
| 112 | until("the initial TOUCHED never arrived", || { |
| 113 | host.touched(&["Garden.one".into()]); |
| 114 | touched.load(Ordering::SeqCst) > 0 |
| 115 | }); |
| 116 | let mut remote = HostedRemote::new(&guest, "Garden.one"); |
| 117 | until("TOUCHED did not cache the current stamp", || { |
| 118 | let before = relay.state.lock().unwrap().access_bytes; |
| 119 | assert_eq!( |
| 120 | HostedRemote::new(&guest, "Garden.one").stamp().unwrap(), |
| 121 | stamp |
| 122 | ); |
| 123 | relay.state.lock().unwrap().access_bytes == before |
| 124 | }); |
| 125 | let before = relay.state.lock().unwrap().access_bytes; |
| 126 | for at in 1..=8 { |
| 127 | let spot = Spot { |
| 128 | text: text.into(), |
| 129 | offset: at, |
| 130 | }; |
| 131 | let presence = Presence { |
| 132 | page: Some(space.into()), |
| 133 | caret: Some(Caret { |
| 134 | anchor: spot, |
| 135 | focus: spot, |
| 136 | }), |
| 137 | ..Presence::default() |
| 138 | }; |
| 139 | host.set_presence(presence.clone()); |
| 140 | until("ordinary presence never reached the guest", || { |
| 141 | guest |
| 142 | .peers() |
| 143 | .iter() |
| 144 | .any(|peer| peer.hello.peer == host_id && peer.presence.as_ref() == Some(&presence)) |
| 145 | }); |
| 146 | assert_eq!(remote.stamp().unwrap(), stamp); |
| 147 | } |
| 148 | assert_eq!( |
| 149 | relay.state.lock().unwrap().access_bytes, |
| 150 | before, |
| 151 | "typing caused remote stamp or image requests" |
| 152 | ); |
| 153 | |
| 154 | relay.disconnect_presence(); |
| 155 | until("the presence host never left", || { |
| 156 | !guest.peers().iter().any(|peer| peer.hello.peer == host_id) |
| 157 | }); |
| 158 | assert_eq!(guest.host().unwrap().peer, host_id); |
| 159 | let access_connections = relay.state.lock().unwrap().access_connections; |
| 160 | assert_eq!(access_connections, 1); |
| 161 | std::fs::write(folder.join("reachable.bin"), b"still connected").unwrap(); |
| 162 | assert_eq!( |
| 163 | storage.read_file("reachable.bin", 1024).unwrap(), |
| 164 | b"still connected" |
| 165 | ); |
| 166 | let missed = server::typed(&image, space, text, 0..8, "Missed"); |
| 167 | std::fs::write(&file, &missed).unwrap(); |
| 168 | let before = touched.load(Ordering::SeqCst); |
| 169 | host.touched(&["Garden.one".into()]); |
| 170 | std::thread::sleep(std::time::Duration::from_millis(200)); |
| 171 | assert_eq!( |
| 172 | touched.load(Ordering::SeqCst), |
| 173 | before, |
| 174 | "the isolated guest received TOUCHED" |
| 175 | ); |
| 176 | assert!( |
| 177 | server::page_texts(&section.page(space).unwrap()).contains(&"Original text".to_owned()) |
| 178 | ); |
| 179 | |
| 180 | relay.state.lock().unwrap().blocked = false; |
| 181 | until("the presence host never returned", || { |
| 182 | guest.peers().iter().any(|peer| peer.hello.peer == host_id) |
| 183 | }); |
| 184 | until( |
| 185 | "the missed change never reconciled after presence returned", |
| 186 | || server::page_texts(&section.page(space).unwrap()).contains(&"Missed text".to_owned()), |
| 187 | ); |
| 188 | assert_eq!(guest.host().unwrap().peer, host_id); |
| 189 | assert_eq!( |
| 190 | relay.state.lock().unwrap().access_connections, |
| 191 | access_connections |
| 192 | ); |
| 193 | assert_eq!(section.replica().snapshot().unwrap(), missed); |
| 194 | background.stop(); |
| 195 | } |
| 196 | |
| 197 | /// While the host is away a guest's edits wait in its replica, the notebook opens from its |
| 198 | /// last listing, and once the host is back the edits reach its file. |
| 199 | #[test] |
| 200 | fn a_guest_queues_while_the_host_is_away() { |
| 201 | let directory = tempfile::tempdir().unwrap(); |
| 202 | let folder = notebook(directory.path()); |
| 203 | let url = relay(Default::default()); |
| 204 | let sharing = Sharing::new("").unwrap(); |
| 205 | let host_cache = directory.path().join("host"); |
| 206 | let host = host(&folder, &host_cache, &sharing, &url); |
| 207 | let cache = directory.path().join("grace"); |
| 208 | let (guest, notebook) = guest("Grace", &code(&host), &url, &cache); |
| 209 | let section = open(&notebook, &guest, "Garden.one", None); |
| 210 | let file = folder.join("Garden.one"); |
| 211 | let image = std::fs::read(&file).unwrap(); |
| 212 | |
| 213 | // Ada's computer goes to sleep. |
| 214 | let sharing = host.sharing(); |
| 215 | drop(host); |
| 216 | until("the host never left", || guest.host().is_none()); |
| 217 | let id = replace(&section, &image, 0..8, "Offline"); |
| 218 | until("the section never said the host was away", || { |
| 219 | section |
| 220 | .sync_status() |
| 221 | .is_ok_and(|status| status.state() == SyncState::NotConnected && status.queued > 0) |
| 222 | }); |
| 223 | assert_eq!(std::fs::read(&file).unwrap(), image); |
| 224 | // The notebook opens from its last listing while the host is away. |
| 225 | let reopened = Notebook::open_hosted(Arc::clone(&guest), &cache).unwrap(); |
| 226 | assert_eq!(reopened.catalog().sections.len(), 2); |
| 227 | |
| 228 | let host = self::host(&folder, &host_cache, &sharing, &url); |
| 229 | published(&section, id); |
| 230 | assert_eq!( |
| 231 | server::text(&std::fs::read(&file).unwrap()).2, |
| 232 | "Offline text" |
| 233 | ); |
| 234 | drop(host); |
| 235 | } |
| 236 | |
| 237 | /// Two guests that change the same words while apart: the second to publish gets OneNote's |
| 238 | /// conflict page, in its replica and in the host's file. |
| 239 | #[test] |
| 240 | fn two_guests_on_one_page_conflict_as_on_a_share() { |
| 241 | let directory = tempfile::tempdir().unwrap(); |
| 242 | let folder = notebook(directory.path()); |
| 243 | let url = relay(Default::default()); |
| 244 | let sharing = Sharing::new("").unwrap(); |
| 245 | let host_cache = directory.path().join("host"); |
| 246 | let host = host(&folder, &host_cache, &sharing, &url); |
| 247 | let code = code(&host); |
| 248 | let (grace, grace_notebook) = guest("Grace", &code, &url, &directory.path().join("grace")); |
| 249 | let (alan, alan_notebook) = guest("Alan", &code, &url, &directory.path().join("alan")); |
| 250 | let graces = open(&grace_notebook, &grace, "Garden.one", None); |
| 251 | let alans = open(&alan_notebook, &alan, "Garden.one", None); |
| 252 | let file = folder.join("Garden.one"); |
| 253 | let image = std::fs::read(&file).unwrap(); |
| 254 | |
| 255 | let sharing = host.sharing(); |
| 256 | drop(host); |
| 257 | until("the host never left", || { |
| 258 | grace.host().is_none() && alan.host().is_none() |
| 259 | }); |
| 260 | let first = replace(&graces, &image, 0..8, "Grace's"); |
| 261 | let second = replace(&alans, &image, 0..8, "Alan's"); |
| 262 | let host = self::host(&folder, &host_cache, &sharing, &url); |
| 263 | published(&graces, first); |
| 264 | published(&alans, second); |
| 265 | let stored = std::fs::read(&file).unwrap(); |
| 266 | let conflicts = server::conflicts(&stored); |
| 267 | assert_eq!(conflicts.len(), 1, "one page holds a conflict page"); |
| 268 | let (user, kept) = &conflicts[0].1[0]; |
| 269 | assert_eq!(user, "Guest"); |
| 270 | let texts: Vec<String> = server::pages(&stored) |
| 271 | .iter() |
| 272 | .flat_map(|(_, page)| server::page_texts(page)) |
| 273 | .chain(kept.iter().cloned()) |
| 274 | .collect(); |
| 275 | assert!( |
| 276 | texts.contains(&"Grace's text".to_owned()) && texts.contains(&"Alan's text".to_owned()), |
| 277 | "{texts:?}" |
| 278 | ); |
| 279 | until("the host's conflict never reached a guest", || { |
| 280 | !alans.conflicts().unwrap().is_empty() || !graces.conflicts().unwrap().is_empty() |
| 281 | }); |
| 282 | drop(host); |
| 283 | } |
| 284 | |
| 285 | /// A protected section's guest unlocks it with the password; the host only ever stores |
| 286 | /// what the guest sealed. |
| 287 | #[test] |
| 288 | fn a_protected_section_passes_through_as_ciphertext() { |
| 289 | let directory = tempfile::tempdir().unwrap(); |
| 290 | let folder = notebook(directory.path()); |
| 291 | let url = relay(Default::default()); |
| 292 | let sharing = Sharing::new("").unwrap(); |
| 293 | let host = host(&folder, &directory.path().join("host"), &sharing, &url); |
| 294 | let (guest, notebook) = guest("Grace", &code(&host), &url, &directory.path().join("grace")); |
| 295 | assert!(notebook.unlock("Sealed.one", "wrong password").is_err()); |
| 296 | let key = notebook.unlock("Sealed.one", PASSWORD).unwrap(); |
| 297 | let section = open(&notebook, &guest, "Sealed.one", Some(&key)); |
| 298 | let file = folder.join("Sealed.one"); |
| 299 | let arena = Arena::default(); |
| 300 | let mut unlocked = |
| 301 | onestore::Section::unlock(&arena, std::fs::read(&file).unwrap(), &key).unwrap(); |
| 302 | let (space, ..) = unlocked.pages().unwrap()[0]; |
| 303 | let page = unlocked.page(space).unwrap(); |
| 304 | let text = page |
| 305 | .objects |
| 306 | .iter() |
| 307 | .find_map(|object| match object { |
| 308 | onestore::page::PageObject::Outline(outline) => outline |
| 309 | .paragraphs |
| 310 | .iter() |
| 311 | .find_map(|p| p.text().map(|t| t.id)), |
| 312 | _ => None, |
| 313 | }) |
| 314 | .unwrap(); |
| 315 | let id = replaced(&section, space, text, 0..6, "Guarded"); |
| 316 | published(&section, id); |
| 317 | let stored = std::fs::read(&file).unwrap(); |
| 318 | let marker = "Guarded" |
| 319 | .encode_utf16() |
| 320 | .flat_map(u16::to_le_bytes) |
| 321 | .collect::<Vec<u8>>(); |
| 322 | assert!( |
| 323 | !stored.windows(marker.len()).any(|window| window == marker), |
| 324 | "the host's file holds the new text in the clear" |
| 325 | ); |
| 326 | assert!(onestore::Section::open(&Arena::default(), stored.clone()).is_err()); |
| 327 | let arena = Arena::default(); |
| 328 | let reread = onestore::Section::unlock(&arena, stored, &key).unwrap(); |
| 329 | let texts = server::page_texts(&reread.page(space).unwrap()); |
| 330 | assert!(texts.contains(&"Guarded text".to_owned()), "{texts:?}"); |
| 331 | } |
| 332 | |
| 333 | /// A wrong code is refused without the guest learning anything; the host's code burns after |
| 334 | /// too many wrong tries and is replaced by new words; too many wrong codes from one network |
| 335 | /// lock it out; and a guest can't reach outside the notebook or presence's secret. |
| 336 | #[test] |
| 337 | fn wrong_codes_are_refused_and_counted() { |
| 338 | let directory = tempfile::tempdir().unwrap(); |
| 339 | let folder = notebook(directory.path()); |
| 340 | let url = relay(relay::server::Config { |
| 341 | burn_after: 2, |
| 342 | failures_per_minute: 3, |
| 343 | ..Default::default() |
| 344 | }); |
| 345 | let sharing = Sharing::new("").unwrap(); |
| 346 | let host = host(&folder, &directory.path().join("host"), &sharing, &url); |
| 347 | let code = code(&host); |
| 348 | let (number, secret) = notebook::live::code::parse(&code).unwrap(); |
| 349 | let wrong = notebook::live::code::format(number, &mistaken(&secret)).unwrap(); |
| 350 | let join = |code: &str| share::join(hello("Mallory"), code, "", None, Some(&url)); |
| 351 | assert_eq!(join("not a code").unwrap_err(), Refusal::Malformed); |
| 352 | // A symbol mistyped fails its check here, spending none of the two tries before a burn. |
| 353 | let typo = format!( |
| 354 | "{}{}", |
| 355 | if code.starts_with('7') { '8' } else { '7' }, |
| 356 | &code[1..] |
| 357 | ); |
| 358 | assert_eq!(join(&typo).unwrap_err(), Refusal::Malformed); |
| 359 | assert_eq!( |
| 360 | join(&code.to_lowercase().replace('-', " ")).map(|_| ()), |
| 361 | Ok(()) |
| 362 | ); |
| 363 | assert_eq!(join(&wrong).unwrap_err(), Refusal::Wrong); |
| 364 | assert_eq!(join(&wrong).unwrap_err(), Refusal::Wrong); |
| 365 | // The code burned, and the host shares a new secret, under a number of its own. |
| 366 | until("the code never changed", || { |
| 367 | host.code() |
| 368 | .is_some_and(|now| now != code && share::code(&now).is_some()) |
| 369 | }); |
| 370 | let fresh = host.code().unwrap(); |
| 371 | assert!(matches!( |
| 372 | join(&code).unwrap_err(), |
| 373 | Refusal::Expired | Refusal::NoOne |
| 374 | )); |
| 375 | let (number, secret) = notebook::live::code::parse(&fresh).unwrap(); |
| 376 | let wrong = notebook::live::code::format(number, &mistaken(&secret)).unwrap(); |
| 377 | assert_eq!(join(&wrong).unwrap_err(), Refusal::Wrong); |
| 378 | // A third wrong code in a minute locks this network out, even from the right code. |
| 379 | assert!(matches!( |
| 380 | join(&fresh).unwrap_err(), |
| 381 | Refusal::TooMany(Some(_)) |
| 382 | )); |
| 383 | |
| 384 | // Joined, a guest still can't reach outside the notebook. |
| 385 | std::fs::create_dir_all(folder.join(".snowbound")).unwrap(); |
| 386 | std::fs::write(folder.join(".snowbound/live.json"), b"{}").unwrap(); |
| 387 | std::fs::write(directory.path().join("secret.txt"), b"secret").unwrap(); |
| 388 | let url = relay(Default::default()); |
| 389 | let host = self::host(&folder, &directory.path().join("host"), &sharing, &url); |
| 390 | let (_guest, notebook) = guest( |
| 391 | "Grace", |
| 392 | &self::code(&host), |
| 393 | &url, |
| 394 | &directory.path().join("g"), |
| 395 | ); |
| 396 | let storage = notebook.into_storage(); |
| 397 | for path in [ |
| 398 | "../secret.txt", |
| 399 | ".snowbound/live.json", |
| 400 | "/etc/hosts", |
| 401 | "a//b", |
| 402 | ] { |
| 403 | let error = storage.read_file(path, 1024).unwrap_err(); |
| 404 | assert!( |
| 405 | error.to_string().contains("Outside the notebook"), |
| 406 | "{path}: {error}" |
| 407 | ); |
| 408 | } |
| 409 | assert!(storage.rename_root("Elsewhere", &[]).is_err()); |
| 410 | } |
| 411 | |
| 412 | /// Files larger than one message go up and come back a chunk at a time, through a relay that |
| 413 | /// hangs up on a peer with more than its queue waiting. |
| 414 | #[test] |
| 415 | fn large_files_travel_in_chunks() { |
| 416 | let directory = tempfile::tempdir().unwrap(); |
| 417 | let folder = notebook(directory.path()); |
| 418 | let url = relay(relay::server::Config { |
| 419 | queue: 400 << 10, |
| 420 | ..Default::default() |
| 421 | }); |
| 422 | let sharing = Sharing::new("").unwrap(); |
| 423 | let host = host(&folder, &directory.path().join("host"), &sharing, &url); |
| 424 | let (_guest, notebook) = guest("Grace", &code(&host), &url, &directory.path().join("grace")); |
| 425 | let storage = notebook.into_storage(); |
| 426 | let bytes: Vec<u8> = (0..3_000_000u32).map(|at| (at * 7 % 251) as u8).collect(); |
| 427 | storage |
| 428 | .create("Garden_onefiles/big.bin", &bytes) |
| 429 | .unwrap_err(); |
| 430 | std::fs::create_dir(folder.join("Garden_onefiles")).unwrap(); |
| 431 | storage.create("Garden_onefiles/big.bin", &bytes).unwrap(); |
| 432 | assert_eq!( |
| 433 | std::fs::read(folder.join("Garden_onefiles/big.bin")).unwrap(), |
| 434 | bytes |
| 435 | ); |
| 436 | assert_eq!( |
| 437 | storage |
| 438 | .read_file("Garden_onefiles/big.bin", 4 << 20) |
| 439 | .unwrap(), |
| 440 | bytes |
| 441 | ); |
| 442 | assert!( |
| 443 | storage |
| 444 | .read_file("Garden_onefiles/big.bin", 1 << 20) |
| 445 | .is_err() |
| 446 | ); |
| 447 | } |
| 448 | |
| 449 | #[test] |
| 450 | fn large_section_appends_keep_the_room_connected_and_guests_converge() { |
| 451 | use notebook::{EditStatus, Replica, live::share::HostedRemote}; |
| 452 | use onestore::op::{Edit, Op, PageOp}; |
| 453 | use std::time::{Duration, Instant}; |
| 454 | |
| 455 | let directory = tempfile::tempdir().unwrap(); |
| 456 | let folder = notebook(directory.path()); |
| 457 | let file = folder.join("Garden.one"); |
| 458 | let mut original = "A".repeat(512 << 10); |
| 459 | let image = onestore::create_section("Garden.one", &original, "Fixture").unwrap(); |
| 460 | std::fs::write(&file, &image).unwrap(); |
| 461 | let (space, text, _) = server::text(&image); |
| 462 | let arena = Arena::default(); |
| 463 | let mut section = onestore::Section::open(&arena, image).unwrap(); |
| 464 | section |
| 465 | .apply( |
| 466 | "Fixture", |
| 467 | &Edit { |
| 468 | at: 134_000_000_000_000_000, |
| 469 | ops: vec![Op::Page { |
| 470 | space, |
| 471 | op: PageOp::Text { |
| 472 | text, |
| 473 | range: 0..1, |
| 474 | with: "a".into(), |
| 475 | }, |
| 476 | }], |
| 477 | }, |
| 478 | ) |
| 479 | .unwrap(); |
| 480 | section.seal().unwrap().unwrap().commit_file(&file).unwrap(); |
| 481 | original.replace_range(0..1, "a"); |
| 482 | let url = relay(Default::default()); |
| 483 | let sharing = Sharing::new("").unwrap(); |
| 484 | let host = host(&folder, &directory.path().join("host"), &sharing, &url); |
| 485 | let code = code(&host); |
| 486 | let (alice, alice_notebook) = guest("Alice", &code, &url, &directory.path().join("alice")); |
| 487 | let (bob, bob_notebook) = guest("Bob", &code, &url, &directory.path().join("bob")); |
| 488 | until("the guests never joined the room", || { |
| 489 | host.guests().len() == 2 |
| 490 | }); |
| 491 | let image = bob_notebook.read_section("Garden.one").unwrap(); |
| 492 | assert_eq!(alice_notebook.read_section("Garden.one").unwrap(), image); |
| 493 | let (space, text, _) = server::text(&image); |
| 494 | let writer = Replica::create(directory.path().join("writer.sqlite"), &image).unwrap(); |
| 495 | let reader = Replica::create(directory.path().join("reader.sqlite"), &image).unwrap(); |
| 496 | let append = "B".repeat(192 << 10); |
| 497 | let expected = format!("{original}{append}"); |
| 498 | let id = writer |
| 499 | .apply( |
| 500 | "Alice", |
| 501 | Edit { |
| 502 | at: 134_000_000_000_000_000, |
| 503 | ops: vec![Op::Page { |
| 504 | space, |
| 505 | op: PageOp::Text { |
| 506 | text, |
| 507 | range: original.len() as u32..original.len() as u32, |
| 508 | with: append, |
| 509 | }, |
| 510 | }], |
| 511 | }, |
| 512 | ) |
| 513 | .unwrap(); |
| 514 | writer |
| 515 | .sync_once(&mut HostedRemote::new(&alice, "Garden.one")) |
| 516 | .unwrap(); |
| 517 | let stored = std::fs::read(&file).unwrap(); |
| 518 | let added = stored.len() - image.len(); |
| 519 | assert!(added > relay::server::Config::default().max_message); |
| 520 | assert!(added + 1024 < stored.len() / 2); |
| 521 | assert_eq!(server::text(&stored).2, expected); |
| 522 | let Some(EditStatus::Published { revision }) = writer.status(id).unwrap() else { |
| 523 | panic!("the append has no durable receipt"); |
| 524 | }; |
| 525 | let store = onestore::Store::parse(&stored).unwrap(); |
| 526 | let index = onestore::RevisionIndex::parse(&store).unwrap(); |
| 527 | assert!(index.spaces[&space].revisions.contains_key(&revision)); |
| 528 | assert!(writer.pending().unwrap().is_empty()); |
| 529 | let mut remote = HostedRemote::new(&bob, "Garden.one"); |
| 530 | reader.sync_once(&mut remote).unwrap(); |
| 531 | assert_eq!(reader.snapshot().unwrap(), stored); |
| 532 | let deadline = Instant::now() + Duration::from_millis(1200); |
| 533 | while Instant::now() < deadline { |
| 534 | assert_eq!(host.guests().len(), 2, "the section append broke the room"); |
| 535 | std::thread::sleep(Duration::from_millis(10)); |
| 536 | } |
| 537 | } |
| 538 | |
| 539 | /// A guest that floods its host with requests is hung up on once too many wait, having had |
| 540 | /// answers to few of them, and the host goes on serving the others. |
| 541 | #[test] |
| 542 | fn a_flooding_guest_is_hung_up_on() { |
| 543 | use notebook::live::{ |
| 544 | Event, Live, Room, |
| 545 | wire::{Bye, Request, kind}, |
| 546 | }; |
| 547 | use std::sync::{ |
| 548 | Mutex, |
| 549 | atomic::{AtomicUsize, Ordering}, |
| 550 | }; |
| 551 | let directory = tempfile::tempdir().unwrap(); |
| 552 | let folder = notebook(directory.path()); |
| 553 | let url = relay(Default::default()); |
| 554 | let sharing = Sharing::new("").unwrap(); |
| 555 | let host = host(&folder, &directory.path().join("host"), &sharing, &url); |
| 556 | let code = code(&host); |
| 557 | let (grace, notebook) = guest("Grace", &code, &url, &directory.path().join("grace")); |
| 558 | let welcome = share::join(hello("Mallory"), &code, "", None, Some(&url)).unwrap(); |
| 559 | let replies = Arc::new(AtomicUsize::new(0)); |
| 560 | let bye = Arc::new(Mutex::new(None)); |
| 561 | let (counted, said) = (Arc::clone(&replies), Arc::clone(&bye)); |
| 562 | let mallory = Live::start( |
| 563 | hello("Mallory"), |
| 564 | &Room::Notebook(welcome.secret), |
| 565 | None, |
| 566 | Some(&url), |
| 567 | move |event| match event { |
| 568 | Event::Frame { |
| 569 | kind: kind::REPLY, .. |
| 570 | } => { |
| 571 | counted.fetch_add(1, Ordering::Relaxed); |
| 572 | } |
| 573 | Event::Frame { |
| 574 | kind: kind::BYE, |
| 575 | body, |
| 576 | .. |
| 577 | } => *said.lock().unwrap() = minicbor::decode::<Bye>(body).ok(), |
| 578 | _ => {} |
| 579 | }, |
| 580 | ) |
| 581 | .unwrap(); |
| 582 | // The line to the host, while Mallory's stream to it is open. |
| 583 | let served = |live: &Live| { |
| 584 | let host = live |
| 585 | .peers() |
| 586 | .into_iter() |
| 587 | .find(|peer| peer.hello.serves == Some(welcome.share))?; |
| 588 | live.line(&host.hello.peer) |
| 589 | }; |
| 590 | until("Mallory never met the host", || served(&mallory).is_some()); |
| 591 | let line = served(&mallory).unwrap(); |
| 592 | const SENT: u64 = 5000; |
| 593 | for id in 0..SENT { |
| 594 | let request = Request { |
| 595 | id, |
| 596 | path: "Garden.one".into(), |
| 597 | ..Request::default() |
| 598 | }; |
| 599 | if line.send(kind::STAMP, &request).is_err() { |
| 600 | break; |
| 601 | } |
| 602 | } |
| 603 | until("the host never hung up on Mallory", || { |
| 604 | bye.lock().unwrap().is_some() && served(&mallory).is_none() |
| 605 | }); |
| 606 | assert_eq!(bye.lock().unwrap().as_ref().unwrap().reason, "flooded"); |
| 607 | let answered = replies.load(Ordering::Relaxed); |
| 608 | // At most the burst a guest may start at once, what waits for the workers, and what the |
| 609 | // rate refills while the flood arrives. |
| 610 | assert!(answered < 300, "Mallory had {answered} answers of {SENT}"); |
| 611 | // Grace, asking at her own pace, is served as before. |
| 612 | assert!(grace.host().is_some()); |
| 613 | assert_eq!( |
| 614 | notebook.read_section("Garden.one").unwrap(), |
| 615 | std::fs::read(folder.join("Garden.one")).unwrap() |
| 616 | ); |
| 617 | } |
| 618 | |
| 619 | /// Stopping a share lets every guest go, saying so, and leaves its code and secret for no one: |
| 620 | /// the code no longer opens anything, and sharing again makes new ones. |
| 621 | #[test] |
| 622 | fn stopping_lets_every_guest_go_and_retires_the_code() { |
| 623 | let directory = tempfile::tempdir().unwrap(); |
| 624 | let folder = notebook(directory.path()); |
| 625 | let url = relay(Default::default()); |
| 626 | let sharing = Sharing::new("").unwrap(); |
| 627 | let host = host(&folder, &directory.path().join("host"), &sharing, &url); |
| 628 | let code = code(&host); |
| 629 | let (guest, _notebook) = guest("Grace", &code, &url, &directory.path().join("grace")); |
| 630 | host.stop(); |
| 631 | until("the guest never heard the host stop", || { |
| 632 | guest.ended() == Some(share::Ended::Stopped) && guest.host().is_none() |
| 633 | }); |
| 634 | assert!(host.code().is_none() && host.guests().is_empty()); |
| 635 | assert!(matches!( |
| 636 | share::join(hello("Alan"), &code, "", None, Some(&url)).unwrap_err(), |
| 637 | Refusal::NoOne | Refusal::TimedOut |
| 638 | )); |
| 639 | let again = Sharing::new("").unwrap(); |
| 640 | assert!(again.secret != sharing.secret && again.share != sharing.share); |
| 641 | assert_ne!(again.code, sharing.code); |
| 642 | } |