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()); +}