From 8e74dcce15aac05c790b8a2ad57cfb37d0f2f18c Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sun, 11 Oct 2026 21:36:47 -0700 Subject: [PATCH] feat(mobile): expose native Live Share guest sessions Reuse hosted notebook discovery and original replicas for cached browsing, queued edits, search, and explicit sync. Apply the initial offline policy before watching cached queues. Assisted-by: gpt-6.1-sol --- Cargo.lock | 1 + crates/mobile/Cargo.toml | 3 +- crates/mobile/include/snowbound.h | 9 + crates/mobile/src/library.rs | 276 +++++++++++++- crates/mobile/src/tests.rs | 583 ++++++++++++++++++++++++++++++ 5 files changed, 861 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 89343ba90781022fc673ee0c101e64f5c4ad9c5f..dffc26b3ff23e45de5ce99533f92bfbcf8bdc6c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2068,6 +2068,7 @@ dependencies = [ "onestore", "parley", "pollster", + "relay", "resvg", "serde", "serde_json", diff --git a/crates/mobile/Cargo.toml b/crates/mobile/Cargo.toml index 1b7c682a869f4d2d6fc0c94dbf62cb39d31803cb..164ce1fd01a5b884d3e7e9814dc600f0d3e18fd5 100644 --- a/crates/mobile/Cargo.toml +++ b/crates/mobile/Cargo.toml @@ -12,7 +12,7 @@ crate-type = ["staticlib"] crash-report = { path = "../crash-report" } canvas = { path = "../canvas", features = ["interaction", "pdf"] } draw = { path = "../draw" } -notebook = { path = "../notebook", features = ["smb"] } +notebook = { path = "../notebook", features = ["smb", "live"] } onestore = { path = "../onestore" } wgpu.workspace = true parley.workspace = true @@ -28,4 +28,5 @@ objc2 = "0.5.2" objc2-foundation = { version = "0.2.2", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSRange", "NSString", "NSThread"] } [dev-dependencies] +relay = { path = "../relay" } tempfile = "3" diff --git a/crates/mobile/include/snowbound.h b/crates/mobile/include/snowbound.h index 8e1267e697c4aa4816b6dcacb6ec13547907e68d..d8a84a2ff5a329b598dfd9e015d2967e8827d44c 100644 --- a/crates/mobile/include/snowbound.h +++ b/crates/mobile/include/snowbound.h @@ -30,6 +30,15 @@ bool sb_tag_icon(uint16_t shape, bool checked, uint32_t pixels, uint8_t *rgba); const Library *sb_library_open(const char *path, const char *cache, bool local, char **error); const Library *sb_library_server(const char *address, const char *share, const char *user, const char *password, const char *domain, const char *root, const char *cache, char **error); +// Callback/token live until the blocking join returns; false cancels, approving reports host approval. +typedef bool (*sb_join_wait)(uintptr_t token, bool approving); +// Private credential/refusal JSON; persist credential before hosted open, then free with sb_string_free. +char *sb_live_join(const char *author, const char *device, const char *code, const char *password, + const char *relay, bool local_network, sb_join_wait waiting, uintptr_t token); +// Null relay disables relay, false local_network disables LAN; offline guards cached edits before first watch. +const Library *sb_library_hosted(const char *credential, const char *author, const char *device, + const char *relay, bool local_network, bool offline, const char *cache, char **error); +char *sb_library_live_status(const Library *library); bool sb_notebook_create(const char *path, const char *cache, const char *author, const char *date, const char *time, char **error); bool sb_notebook_moved(const char *cache, const char *from, const char *to, char **error); diff --git a/crates/mobile/src/library.rs b/crates/mobile/src/library.rs index eae5500df38c6a32f2856013f38014c4724b9e7e..1bac8980c907b6d14fd41ca030c33a03fb6af662 100644 --- a/crates/mobile/src/library.rs +++ b/crates/mobile/src/library.rs @@ -7,6 +7,7 @@ use canvas::search::{Entry, Index, Query, Tagged}; use notebook::{ Remote, Replica, discover::{Folder, Reason, SectionState}, + live::{self, Hello, Reach, share::Guest}, session::{self, Background, Event, Known, Notebook, SyncStatus}, smb::{Client, Credentials}, }; @@ -221,6 +222,17 @@ enum Place { client: Mutex>>, root: String, }, + Hosted(Arc), +} + +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct Joined { + share: [u8; 16], + secret: [u8; 16], + protocol: u16, + notebook: String, + host: String, } /// A share notebook's sections as last listed, so they open from their replicas while the @@ -437,6 +449,59 @@ impl Library { Ok(library) } + fn hosted( + joined: Joined, + author: String, + device: Option, + relay: Option<&str>, + local_network: bool, + offline: bool, + cache: &Path, + ) -> Result { + if joined.protocol != live::wire::VERSION { + return Err(io::Error::from(io::ErrorKind::Unsupported).into()); + } + let mut hello = Hello::new(author, None)?; + hello.device = device; + let guest = Guest::start( + hello, + joined.share, + joined.secret, + local_network.then_some(Reach::Network), + relay, + sync_woken, + )?; + let mut notebook = match Notebook::open_hosted(Arc::clone(&guest), cache) { + Ok(notebook) => notebook, + Err(error) if guest.host().is_none() => { + let deadline = Instant::now() + TIMEOUT; + while guest.host().is_none() && guest.ended().is_none() && Instant::now() < deadline + { + std::thread::sleep(Duration::from_millis(50)); + } + if guest.host().is_none() { + return Err(error.into()); + } + Notebook::open_hosted(Arc::clone(&guest), cache)? + } + Err(error) => return Err(error.into()), + }; + let background = Background::hosted(Arc::clone(&guest), sync_woken)?; + background.set_offline(offline); + background.watch(notebook.replicas()); + Ok(Self { + notebook: Mutex::new(Some(notebook)), + place: Place::Hosted(guest), + cache: cache.to_owned(), + index: Mutex::default(), + background: Some(background), + open: Mutex::default(), + offline: AtomicBool::new(offline), + keys: Default::default(), + pause: Duration::ZERO, + }) + } + /// Has the background sync a share notebook's sections, `files` as `Listed` keeps them. fn watch(&self, files: &BTreeMap) { let (Some(background), Some(replicas)) = (&self.background, self.share_replicas()) else { @@ -574,8 +639,12 @@ impl Library { files: BTreeMap::new(), }; tabs(notebook.catalog(), &self.unlocked(), &mut listed.tabs); - files(notebook.catalog(), &mut listed.files); - if let (Place::Folder(_), Some(background)) = (&self.place, &self.background) { + if matches!(self.place, Place::Share { .. }) { + files(notebook.catalog(), &mut listed.files); + } + if let (Place::Folder(_) | Place::Hosted(_), Some(background)) = + (&self.place, &self.background) + { background.watch(notebook.replicas()); } Ok(listed) @@ -690,6 +759,24 @@ impl Library { notify, )?) } + Place::Hosted(guest) => { + let notebook = self.notebook(); + let notebook = notebook + .as_ref() + .ok_or_else(|| io::Error::from(io::ErrorKind::NotConnected))?; + let cache = notebook.replica_path(path)?; + std::fs::create_dir_all(cache.parent().unwrap_or(&self.cache))?; + let key = self.unlocked().get(path).cloned(); + let replica = + Replica::open_or_create(&cache, key.as_ref(), || notebook.read_section(path))?; + Ok(session::Section::resume_hosted( + path.to_owned(), + replica, + Arc::clone(guest), + offline, + notify, + )?) + } } } } @@ -1136,14 +1223,7 @@ impl Library { for path in &tabs { match open.iter().find(|(open, _)| open == path) { Some((_, shared)) => { - let section = &shared.section; - let mut entries = Vec::new(); - for (space, ..) in section.pages()? { - let modified = index.get(path, space).map_or(0, |entry| entry.modified); - entries.push(Entry::new(path, space, §ion.page(space)?, modified)); - } - index.retain(|entry| entry.section != *path); - entries.into_iter().for_each(|entry| index.set(entry)); + index_replica(index, path, shared.section.replica())?; checked.remove(path); } None => { @@ -1446,6 +1526,20 @@ impl Library { } Place::Folder(root) => local(root.join(path))?, Place::File(file) => local(file.clone())?, + Place::Hosted(_) => { + let cache = self + .notebook() + .as_ref() + .ok_or_else(|| io::Error::from(io::ErrorKind::NotConnected))? + .replica_path(path)?; + let key = self.unlocked().get(path).cloned(); + let replica = Replica::open_or_create(cache, key.as_ref(), || { + Err(io::Error::from(io::ErrorKind::NotFound).into()) + })?; + index_replica(index, path, &replica)?; + checked.remove(path); + return Ok(()); + } }; if checked.get(path).is_none_or(|known| known.stamp != stamp) { let pages = session::stored_pages(&read()?)?; @@ -1468,6 +1562,17 @@ impl Library { } } +fn index_replica(index: &mut Index, path: &str, replica: &Replica) -> Result<()> { + let mut entries = Vec::new(); + for (space, ..) in replica.pages()? { + let modified = index.get(path, space).map_or(0, |entry| entry.modified); + entries.push(Entry::new(path, space, &replica.page(space)?, modified)); + } + index.retain(|entry| entry.section != path); + entries.into_iter().for_each(|entry| index.set(entry)); + Ok(()) +} + type Reader = Box Result>>; /// A local file's change key, its length and modification time, with its reader. @@ -1523,6 +1628,157 @@ pub(crate) fn optional(text: *const c_char) -> Option { (!text.is_null()).then(|| string(text)) } +/// Runs on the joining thread, with a token the host keeps alive until `sb_live_join` +/// returns; false cancels the join, and `approving` reports pending host approval. +pub type JoinWait = extern "C" fn(token: usize, approving: bool) -> bool; + +/// Joins through explicitly supplied relay/LAN routes, blocking off the UI thread. Returns +/// private JSON containing `credential`, or a machine-readable `refusal`; free it with +/// `sb_string_free`. The caller must durably keep the credential before opening its hosted library. +/// Cancellation cannot undo an admission the host has already granted. +/// +/// # Safety +/// `author`, `code` and `password` are NUL-terminated UTF-8; `device` and `relay` may be null. +/// `waiting` and its token remain valid until this call returns; callbacks never overlap. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_live_join( + author: *const c_char, + device: *const c_char, + code: *const c_char, + password: *const c_char, + relay: *const c_char, + local_network: bool, + waiting: JoinWait, + token: usize, +) -> *mut c_char { + use live::share::Refusal; + let password = zeroize::Zeroizing::new(string(password)); + let relay = optional(relay); + let joined = Hello::new(string(author), None) + .map_err(|_| Refusal::Unreachable(live::Trouble::Other)) + .and_then(|mut hello| { + hello.device = optional(device); + live::share::join_while( + hello, + &string(code), + &password, + local_network.then_some(Reach::Network), + relay.as_deref(), + |approving| waiting(token, approving), + ) + }); + let value = match joined { + Ok(welcome) => serde_json::json!({"credential": Joined { + share: welcome.share, + secret: welcome.secret, + protocol: live::wire::VERSION, + notebook: welcome.notebook, + host: welcome.host, + }}), + Err(refusal) => { + let reason = match refusal { + Refusal::Malformed => "malformed", + Refusal::Version { theirs_newer } => { + return json(Ok( + serde_json::json!({"refusal": "version", "theirs_newer": theirs_newer}), + )); + } + Refusal::Wrong => "wrong", + Refusal::NoOne => "no_one", + Refusal::Expired => "expired", + Refusal::TooMany(wait) => { + return json(Ok( + serde_json::json!({"refusal": "too_many", "retry_seconds": wait.map(|wait| wait.as_secs_f64())}), + )); + } + Refusal::Busy => "busy", + Refusal::Unreachable(trouble) => { + let (trouble, status) = match trouble { + live::Trouble::Dns => ("dns", None), + live::Trouble::Unreachable => ("unreachable", None), + live::Trouble::TimedOut => ("timed_out", None), + live::Trouble::ProxyUnreachable => ("proxy_unreachable", None), + live::Trouble::ProxyAuthentication => ("proxy_authentication", None), + live::Trouble::ProxyRefused(status) => ("proxy_refused", Some(status)), + live::Trouble::Certificate => ("certificate", None), + live::Trouble::Blocked => ("blocked", None), + live::Trouble::Other => ("other", None), + }; + return json(Ok( + serde_json::json!({"refusal": "unreachable", "trouble": trouble, "status": status}), + )); + } + Refusal::TimedOut => "timed_out", + Refusal::Declined => "declined", + Refusal::Cancelled => "cancelled", + Refusal::NotAdmitted => "not_admitted", + }; + serde_json::json!({"refusal": reason}) + } + }; + json(Ok(value)) +} + +/// Reopens an already persisted join against its original replicas. A cached notebook +/// opens while the host is absent; the first opening waits at most ten seconds to meet it. +/// `offline` applies before cached edits are watched. +/// Neither this function nor `sb_live_join` persists credentials. +/// +/// # Safety +/// `credential`, `author` and `cache` are NUL-terminated UTF-8; `device` and `relay` may be +/// null. `credential` is the private object returned by `sb_live_join`, without its wrapper. +/// `error` is null or a place for a string; credential parse errors never echo its contents. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_library_hosted( + credential: *const c_char, + author: *const c_char, + device: *const c_char, + relay: *const c_char, + local_network: bool, + offline: bool, + cache: *const c_char, + error: *mut *mut c_char, +) -> *const Library { + let credential = zeroize::Zeroizing::new(string(credential)); + let relay = optional(relay); + shared( + serde_json::from_str(&credential) + .map_err(|_| io::Error::from(io::ErrorKind::InvalidData).into()) + .and_then(|joined| { + Library::hosted( + joined, + string(author), + optional(device), + relay.as_deref(), + local_network, + offline, + Path::new(&string(cache)), + ) + }), + error, + ) +} + +/// Derived Live Share status (`connected`, `not_connected`, `stopped`, or `removed`) and +/// the connected host's name, never its credential; null for other notebook sources. +/// On return to connected, the host can call `sb_library_sync_now` while not Work Offline. +#[unsafe(no_mangle)] +pub extern "C" fn sb_library_live_status(library: &Library) -> *mut c_char { + let Place::Hosted(guest) = &library.place else { + return std::ptr::null_mut(); + }; + let host = guest.host(); + let state = match (host.is_some(), guest.ended()) { + (true, _) => "connected", + (_, Some(live::share::Ended::Stopped)) => "stopped", + (_, Some(live::share::Ended::Removed)) => "removed", + _ => "not_connected", + }; + json(Ok( + serde_json::json!({"state": state, "host": host.map(|host| host.name.clone())}), + )) +} + /// The notebook folder or lone section file at `path`, its section replicas kept in the /// directory `cache`; null with `error` set if it cannot be read. A folder `local` to this /// device has the host report its changes to `sb_library_touched`; any other is checked diff --git a/crates/mobile/src/tests.rs b/crates/mobile/src/tests.rs index 35608a5e858586e99743e5f6c750874c8b8fefdc..f6927a989570a21116cb0736d433679bc87e2814 100644 --- a/crates/mobile/src/tests.rs +++ b/crates/mobile/src/tests.rs @@ -1738,3 +1738,586 @@ fn detached_editor_keeps_selection_and_undo_when_reattachment_fails() { assert!(sb_view_undo(&mut view, true)); assert_eq!(view.canvas.active().shown_text(), text); } + +struct LiveRelay(std::process::Child); + +impl LiveRelay { + fn start(test: &str, root: &Path) -> (Self, String) { + let receipt = root.join("relay-address"); + let child = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", test, "--nocapture"]) + .env("SNOWBOUND_MOBILE_TEST_RELAY", &receipt) + .stdout(std::process::Stdio::null()) + .spawn() + .unwrap(); + let mut relay = Self(child); + assert!(eventually(|| { + assert!(relay.0.try_wait().unwrap().is_none()); + receipt.exists() + })); + (relay, std::fs::read_to_string(receipt).unwrap()) + } +} + +impl Drop for LiveRelay { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn live_relay_child() { + if let Some(receipt) = std::env::var_os("SNOWBOUND_MOBILE_TEST_RELAY") { + std::thread::spawn(|| { + std::thread::sleep(Duration::from_secs(180)); + std::process::exit(124); + }); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let receipt = PathBuf::from(receipt); + let partial = receipt.with_extension("partial"); + std::fs::write(&partial, format!("ws://{}", listener.local_addr().unwrap())).unwrap(); + std::fs::rename(partial, receipt).unwrap(); + relay::server::serve(listener, Default::default()).unwrap(); + } +} + +fn live_host( + root: &Path, + cache: &Path, + sharing: notebook::live::share::Sharing, + relay: &str, +) -> notebook::live::share::Host { + let kept = cache.join("sharing.json"); + let notebook = notebook::session::Notebook::open(root, cache).unwrap(); + notebook::live::share::Host::start( + notebook.into_storage(), + notebook::live::Hello::new("Host".into(), None).unwrap(), + sharing, + "Mobile fixture", + None, + Some(relay), + || {}, + move |sharing| keep_live_fixture(&kept, sharing), + ) + .unwrap() +} + +fn keep_live_fixture(path: &Path, value: &impl serde::Serialize) -> std::io::Result<()> { + use std::io::Write; + let mut options = std::fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + let partial = path.with_extension("partial"); + let mut output = options.open(&partial)?; + output.write_all(&serde_json::to_vec(value)?)?; + output.sync_all()?; + std::fs::rename(partial, path)?; + #[cfg(unix)] + std::fs::File::open(path.parent().unwrap())?.sync_all()?; + Ok(()) +} + +struct JoinControl { + deadline: Instant, + cancelled: AtomicBool, + approving: AtomicBool, +} + +impl JoinControl { + fn new() -> Self { + Self { + deadline: Instant::now() + Duration::from_secs(30), + cancelled: AtomicBool::new(false), + approving: AtomicBool::new(false), + } + } +} + +extern "C" fn live_join_wait(token: usize, approving: bool) -> bool { + // SAFETY: join_live keeps its borrowed JoinControl alive until sb_live_join returns. + let control = unsafe { &*(token as *const JoinControl) }; + control.approving.store(approving, Ordering::Release); + !control.cancelled.load(Ordering::Acquire) && Instant::now() < control.deadline +} + +fn take_json(raw: *mut c_char) -> serde_json::Value { + assert!(!raw.is_null()); + let text = unsafe { CString::from_raw(raw) }; + serde_json::from_slice(text.as_bytes()).unwrap() +} + +fn join_live(code: &str, password: &str, relay: &str, control: &JoinControl) -> serde_json::Value { + let code = CString::new(code).unwrap(); + let password = CString::new(password).unwrap(); + let relay = CString::new(relay).unwrap(); + take_json(unsafe { + library::sb_live_join( + c"Mobile guest".as_ptr(), + c"Owned test device".as_ptr(), + code.as_ptr(), + password.as_ptr(), + relay.as_ptr(), + false, + live_join_wait, + control as *const JoinControl as usize, + ) + }) +} + +fn hosted_c( + credential: &serde_json::Value, + relay: &str, + offline: bool, + cache: &Path, +) -> Arc { + let credential = CString::new(serde_json::to_vec(credential).unwrap()).unwrap(); + let relay = CString::new(relay).unwrap(); + let cache = CString::new(cache.to_str().unwrap()).unwrap(); + let mut error = std::ptr::null_mut(); + let raw = unsafe { + library::sb_library_hosted( + credential.as_ptr(), + c"Mobile guest".as_ptr(), + c"Owned test device".as_ptr(), + relay.as_ptr(), + false, + offline, + cache.as_ptr(), + &mut error, + ) + }; + if !error.is_null() { + let error = unsafe { CString::from_raw(error) }; + panic!("hosted open: {}", error.to_string_lossy()); + } + assert!(!raw.is_null()); + // SAFETY: sb_library_hosted hands the caller one owned Arc reference. + unsafe { Arc::from_raw(raw) } +} + +#[test] +fn live_join_cancels_approval_and_returns_private_per_device_credentials() { + live_relay_child(); + let directory = tempfile::tempdir().unwrap(); + let (_relay, url) = LiveRelay::start( + "tests::live_join_cancels_approval_and_returns_private_per_device_credentials", + directory.path(), + ); + let root = directory.path().join("notebook"); + let cache = directory.path().join("host-cache"); + let creation = onestore::PageCreation::new(None, Some("Mobile fixture"), "Fixture").unwrap(); + drop(notebook::session::Notebook::create(&root, &cache, 0, &creation).unwrap()); + let mut sharing = notebook::live::share::Sharing::new("join password").unwrap(); + sharing.approve = true; + let host = live_host(&root, &cache, sharing, &url); + assert!(eventually(|| host.code().is_some())); + let code = host.code().unwrap(); + assert_eq!( + join_live("malformed", "", &url, &JoinControl::new())["refusal"], + "malformed" + ); + assert_eq!( + join_live(&code, "wrong password", &url, &JoinControl::new())["refusal"], + "wrong" + ); + assert!(host.devices().is_empty()); + for decision in ["cancel", "decline", "allow"] { + let control = JoinControl::new(); + let reply = std::thread::scope(|scope| { + let joined = scope.spawn(|| join_live(&code, "join password", &url, &control)); + assert!(eventually( + || control.approving.load(Ordering::Acquire) && host.requests().len() == 1 + )); + match decision { + "cancel" => control.cancelled.store(true, Ordering::Release), + "decline" => host.decline(&host.requests()[0].peer), + "allow" => host.allow(&host.requests()[0].peer).unwrap(), + _ => unreachable!(), + } + joined.join().unwrap() + }); + match decision { + "allow" => { + let credential = &reply["credential"]; + assert!(credential.is_object()); + let persisted: notebook::live::share::Sharing = + serde_json::from_slice(&std::fs::read(cache.join("sharing.json")).unwrap()) + .unwrap(); + assert!(credential["secret"] == serde_json::json!(persisted.members[0].secret)); + assert!(credential["secret"] != serde_json::json!(persisted.secret)); + assert!(credential.get("room").is_none()); + assert!(credential.get("code").is_none()); + assert!(credential.get("password").is_none()); + assert_eq!(credential["protocol"], notebook::live::wire::VERSION); + let kept = directory.path().join("joined.json"); + keep_live_fixture(&kept, credential).unwrap(); + let credential: serde_json::Value = + serde_json::from_slice(&std::fs::read(kept).unwrap()).unwrap(); + let library = hosted_c( + &credential, + &url, + false, + &directory.path().join("guest-cache"), + ); + assert!(eventually(|| take_json(library::sb_library_live_status( + &library + ))["state"] + == "connected")); + assert_eq!(library.tabs().unwrap().len(), 1); + } + _ => { + assert_eq!( + reply["refusal"], + if decision == "cancel" { + "cancelled" + } else { + "declined" + } + ); + assert!(reply.get("credential").is_none()); + assert!(host.devices().is_empty()); + assert!(eventually(|| host.requests().is_empty())); + } + } + } +} + +#[test] +fn live_guests_reopen_original_offline_edits_search_and_resume_closed_sections() { + live_relay_child(); + let directory = tempfile::tempdir().unwrap(); + let (_relay, url) = LiveRelay::start( + "tests::live_guests_reopen_original_offline_edits_search_and_resume_closed_sections", + directory.path(), + ); + let root = directory.path().join("notebook"); + std::fs::create_dir_all(&root).unwrap(); + for (file, text) in [ + ("Read.one", "Original mobile text"), + ("Other.one", "Closed section text"), + ] { + std::fs::write( + root.join(file), + onestore::create_section(file, text, "Fixture").unwrap(), + ) + .unwrap(); + } + let sealed = onestore::create_section("Sealed.one", "Protected fixture", "Fixture").unwrap(); + std::fs::write( + root.join("Sealed.one"), + onestore::protected::rekey( + &sealed, + None, + Some(&onestore::protected::Key::new("section password").unwrap()), + ) + .unwrap(), + ) + .unwrap(); + let sealed = std::fs::read(root.join("Sealed.one")).unwrap(); + let host_cache = directory.path().join("host-cache"); + let host = live_host( + &root, + &host_cache, + notebook::live::share::Sharing::new("").unwrap(), + &url, + ); + assert!(eventually(|| host.code().is_some())); + let joined = join_live(&host.code().unwrap(), "", &url, &JoinControl::new()); + let kept = directory.path().join("joined.json"); + assert!(joined["credential"].is_object()); + keep_live_fixture(&kept, &joined["credential"]).unwrap(); + let credential: serde_json::Value = + serde_json::from_slice(&std::fs::read(&kept).unwrap()).unwrap(); + let credential = &credential; + let cache = directory.path().join("guest-cache"); + let library = hosted_c(credential, &url, false, &cache); + let tabs = library.tabs().unwrap(); + assert!( + tabs.iter() + .any(|tab| tab.path == "Sealed.one" && tab.locked && !tab.readable) + ); + let share: [u8; 16] = serde_json::from_value(credential["share"].clone()).unwrap(); + let copies = notebook::location::folder(&cache, ¬ebook::live::share::location(&share)); + let replicas = || { + let mut paths: Vec<_> = std::fs::read_dir(&copies) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| { + path.extension() + .is_some_and(|extension| extension == "sqlite") + }) + .collect(); + paths.sort(); + paths + }; + assert!(eventually(|| copies.exists() && replicas().len() == 2)); + let original_replicas = replicas(); + assert!(eventually(|| library + .sync_status() + .iter() + .filter(|sync| sync.state == SyncState::UpToDate as u8) + .count() + == 2)); + let sharing = host.sharing(); + drop(host); + assert!(eventually(|| take_json(library::sb_library_live_status( + &library + ))["state"] + != "connected")); + sb_library_set_offline(&library, true); + let section = open(&library, "Read.one"); + let space = section.shared.section.pages().unwrap()[0].0; + let page = section.shared.section.page(space).unwrap(); + let text = page + .objects + .iter() + .find_map(|object| match object { + onestore::page::PageObject::Outline(outline) => { + outline.paragraphs.iter().find_map(|paragraph| { + paragraph + .text() + .filter(|text| text.text.text().contains("Original mobile text")) + .map(|text| text.id) + }) + } + _ => None, + }) + .unwrap(); + section + .shared + .apply(Edit { + at: library::filetime(), + ops: vec![Op::Page { + space, + op: PageOp::Text { + text, + range: 0..0, + with: "OFFLINE_GUEST_TOKEN ".into(), + }, + }], + }) + .unwrap(); + section.shared.section.written().unwrap(); + let pending = section.shared.section.pending().unwrap(); + assert_eq!(pending.len(), 1); + let identity = section.shared.section.replica().identity().unwrap(); + let shared = Arc::clone(§ion.shared); + drop((section, library)); + Arc::into_inner(shared).unwrap().section.close().unwrap(); + + let reopened: serde_json::Value = + serde_json::from_slice(&std::fs::read(&kept).unwrap()).unwrap(); + let library = hosted_c(&reopened, &url, true, &cache); + assert!( + library + .tabs() + .unwrap() + .iter() + .any(|tab| tab.path == "Read.one" && tab.readable) + ); + assert_eq!(replicas(), original_replicas); + let section = open(&library, "Read.one"); + assert_eq!( + section.shared.section.replica().identity().unwrap(), + identity + ); + assert_eq!(section.shared.section.pending().unwrap(), pending); + assert!( + canvas::search::page_text(§ion.shared.section.page(space).unwrap()) + .contains("OFFLINE_GUEST_TOKEN") + ); + let shared = Arc::clone(§ion.shared); + drop(section); + Arc::into_inner(shared).unwrap().section.close().unwrap(); + assert_eq!( + library::search(&[&library], None, "OFFLINE_GUEST_TOKEN") + .unwrap() + .len(), + 1 + ); + assert_eq!(std::fs::read(root.join("Sealed.one")).unwrap(), sealed); + assert!(!stored_text(&root.join("Read.one"), space).contains("OFFLINE_GUEST_TOKEN")); + let host = live_host(&root, &host_cache, sharing, &url); + assert!(eventually(|| take_json(library::sb_library_live_status( + &library + ))["state"] + == "connected")); + let original = std::fs::read(root.join("Read.one")).unwrap(); + drop(library); + let library = hosted_c(&reopened, &url, true, &cache); + assert!(eventually(|| take_json(library::sb_library_live_status( + &library + ))["state"] + == "connected")); + assert_eq!(replicas(), original_replicas); + for _ in 0..10 { + let retained: Vec<_> = original_replicas + .iter() + .flat_map(|path| notebook::Replica::open(path).unwrap().pending().unwrap()) + .collect(); + assert_eq!(retained, pending); + assert_eq!(std::fs::read(root.join("Read.one")).unwrap(), original); + std::thread::sleep(Duration::from_millis(20)); + } + // Closed-section search cannot override Work Offline while the host is reachable. + for _ in 0..32 { + assert_eq!( + library::search(&[&library], None, "OFFLINE_GUEST_TOKEN") + .unwrap() + .len(), + 1 + ); + assert_eq!(std::fs::read(root.join("Read.one")).unwrap(), original); + } + let retained: Vec<_> = original_replicas + .iter() + .flat_map(|path| notebook::Replica::open(path).unwrap().pending().unwrap()) + .collect(); + assert_eq!(retained, pending); + let section = open(&library, "Read.one"); + assert_eq!( + section.shared.section.replica().identity().unwrap(), + identity + ); + for _ in 0..10 { + assert_eq!(section.shared.section.pending().unwrap(), pending); + assert_eq!(std::fs::read(root.join("Read.one")).unwrap(), original); + std::thread::sleep(Duration::from_millis(20)); + } + assert!(library.sync_status().iter().any(|sync| sync.queued == 1)); + sb_library_sync_now(&library); + assert!(eventually(|| library + .sync_status() + .iter() + .all(|sync| sync.queued == 0))); + assert_eq!( + stored_text(&root.join("Read.one"), space) + .matches("OFFLINE_GUEST_TOKEN") + .count(), + 1 + ); + assert_eq!(std::fs::read(root.join("Sealed.one")).unwrap(), sealed); + assert!(section.shared.section.pending().unwrap().is_empty()); + let shared = Arc::clone(§ion.shared); + drop(section); + Arc::into_inner(shared).unwrap().section.close().unwrap(); + sb_library_set_offline(&library, false); + let other_join = join_live(&host.code().unwrap(), "", &url, &JoinControl::new()); + let other_kept = directory.path().join("other-joined.json"); + keep_live_fixture(&other_kept, &other_join["credential"]).unwrap(); + let other_credential: serde_json::Value = + serde_json::from_slice(&std::fs::read(other_kept).unwrap()).unwrap(); + let other = hosted_c( + &other_credential, + &url, + false, + &directory.path().join("other-guest-cache"), + ); + let secret: [u8; 16] = serde_json::from_value(credential["secret"].clone()).unwrap(); + host.remove(&secret).unwrap(); + assert!(eventually(|| take_json(library::sb_library_live_status( + &library + ))["state"] + == "removed")); + let unaffected = open(&other, "Read.one"); + unaffected + .shared + .apply(Edit { + at: library::filetime(), + ops: vec![Op::Page { + space, + op: PageOp::Text { + text, + range: 0..0, + with: "OTHER_GUEST_TOKEN ".into(), + }, + }], + }) + .unwrap(); + assert!(unaffected.flush(Duration::from_secs(20))); + assert_eq!( + stored_text(&root.join("Read.one"), space) + .matches("OTHER_GUEST_TOKEN") + .count(), + 1 + ); + sb_library_set_offline(&library, true); + let section = open(&library, "Read.one"); + section + .shared + .apply(Edit { + at: library::filetime(), + ops: vec![Op::Page { + space, + op: PageOp::Text { + text, + range: 0..0, + with: "REMOVED_GUEST_TOKEN ".into(), + }, + }], + }) + .unwrap(); + section.shared.section.written().unwrap(); + let pending = section.shared.section.pending().unwrap(); + sb_library_sync_now(&library); + assert!(eventually(|| library.sync_status().iter().any(|sync| sync + .path + == "Read.one" + && sync.state == SyncState::NotConnected as u8 + && sync.queued == 1))); + assert_eq!(section.shared.section.pending().unwrap(), pending); + let shared = Arc::clone(§ion.shared); + drop((section, library)); + Arc::into_inner(shared).unwrap().section.close().unwrap(); + let reopened: serde_json::Value = + serde_json::from_slice(&std::fs::read(&kept).unwrap()).unwrap(); + let library = hosted_c(&reopened, &url, true, &cache); + let section = open(&library, "Read.one"); + assert_eq!(section.shared.section.pending().unwrap(), pending); + assert!( + canvas::search::page_text(§ion.shared.section.page(space).unwrap()) + .contains("REMOVED_GUEST_TOKEN") + ); + assert!(!stored_text(&root.join("Read.one"), space).contains("REMOVED_GUEST_TOKEN")); + assert_eq!(std::fs::read(root.join("Sealed.one")).unwrap(), sealed); +} + +#[test] +fn hosted_credentials_reject_unknown_versions_without_echoing_secret_input() { + let directory = tempfile::tempdir().unwrap(); + let cache = directory.path().join("must-not-be-created"); + let cache = CString::new(cache.to_str().unwrap()).unwrap(); + let wrong_version = serde_json::json!({ + "share": ([1u8; 16]), "secret": ([2u8; 16]), + "protocol": notebook::live::wire::VERSION + 1, + "notebook": "Fixture", "host": "Host", + }) + .to_string(); + for input in ["{SECRET_MUST_NOT_BE_ECHOED", wrong_version.as_str()] { + let input = CString::new(input).unwrap(); + let mut error = std::ptr::null_mut(); + let opened = unsafe { + library::sb_library_hosted( + input.as_ptr(), + c"Guest".as_ptr(), + std::ptr::null(), + std::ptr::null(), + false, + true, + cache.as_ptr(), + &mut error, + ) + }; + assert!(opened.is_null()); + assert!(!error.is_null()); + let error = unsafe { CString::from_raw(error) }; + assert!( + !error + .to_bytes() + .windows(b"SECRET_MUST_NOT_BE_ECHOED".len()) + .any(|bytes| bytes == b"SECRET_MUST_NOT_BE_ECHOED") + ); + } + assert!(!Path::new(cache.to_str().unwrap()).exists()); +} -- 2.54.0