| ... | ... | @@ -1738,3 +1738,586 @@ fn detached_editor_keeps_selection_and_undo_when_reattachment_fails() { |
| 1738 | 1738 | assert!(sb_view_undo(&mut view, true)); |
| 1739 | 1739 | assert_eq!(view.canvas.active().shown_text(), text); |
| 1740 | 1740 | } |
| 1741 | |
| 1742 | struct LiveRelay(std::process::Child); |
| 1743 | |
| 1744 | impl LiveRelay { |
| 1745 | fn start(test: &str, root: &Path) -> (Self, String) { |
| 1746 | let receipt = root.join("relay-address"); |
| 1747 | let child = std::process::Command::new(std::env::current_exe().unwrap()) |
| 1748 | .args(["--exact", test, "--nocapture"]) |
| 1749 | .env("SNOWBOUND_MOBILE_TEST_RELAY", &receipt) |
| 1750 | .stdout(std::process::Stdio::null()) |
| 1751 | .spawn() |
| 1752 | .unwrap(); |
| 1753 | let mut relay = Self(child); |
| 1754 | assert!(eventually(|| { |
| 1755 | assert!(relay.0.try_wait().unwrap().is_none()); |
| 1756 | receipt.exists() |
| 1757 | })); |
| 1758 | (relay, std::fs::read_to_string(receipt).unwrap()) |
| 1759 | } |
| 1760 | } |
| 1761 | |
| 1762 | impl Drop for LiveRelay { |
| 1763 | fn drop(&mut self) { |
| 1764 | let _ = self.0.kill(); |
| 1765 | let _ = self.0.wait(); |
| 1766 | } |
| 1767 | } |
| 1768 | |
| 1769 | fn live_relay_child() { |
| 1770 | if let Some(receipt) = std::env::var_os("SNOWBOUND_MOBILE_TEST_RELAY") { |
| 1771 | std::thread::spawn(|| { |
| 1772 | std::thread::sleep(Duration::from_secs(180)); |
| 1773 | std::process::exit(124); |
| 1774 | }); |
| 1775 | let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); |
| 1776 | let receipt = PathBuf::from(receipt); |
| 1777 | let partial = receipt.with_extension("partial"); |
| 1778 | std::fs::write(&partial, format!("ws://{}", listener.local_addr().unwrap())).unwrap(); |
| 1779 | std::fs::rename(partial, receipt).unwrap(); |
| 1780 | relay::server::serve(listener, Default::default()).unwrap(); |
| 1781 | } |
| 1782 | } |
| 1783 | |
| 1784 | fn live_host( |
| 1785 | root: &Path, |
| 1786 | cache: &Path, |
| 1787 | sharing: notebook::live::share::Sharing, |
| 1788 | relay: &str, |
| 1789 | ) -> notebook::live::share::Host { |
| 1790 | let kept = cache.join("sharing.json"); |
| 1791 | let notebook = notebook::session::Notebook::open(root, cache).unwrap(); |
| 1792 | notebook::live::share::Host::start( |
| 1793 | notebook.into_storage(), |
| 1794 | notebook::live::Hello::new("Host".into(), None).unwrap(), |
| 1795 | sharing, |
| 1796 | "Mobile fixture", |
| 1797 | None, |
| 1798 | Some(relay), |
| 1799 | || {}, |
| 1800 | move |sharing| keep_live_fixture(&kept, sharing), |
| 1801 | ) |
| 1802 | .unwrap() |
| 1803 | } |
| 1804 | |
| 1805 | fn keep_live_fixture(path: &Path, value: &impl serde::Serialize) -> std::io::Result<()> { |
| 1806 | use std::io::Write; |
| 1807 | let mut options = std::fs::OpenOptions::new(); |
| 1808 | options.write(true).create(true).truncate(true); |
| 1809 | #[cfg(unix)] |
| 1810 | std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); |
| 1811 | let partial = path.with_extension("partial"); |
| 1812 | let mut output = options.open(&partial)?; |
| 1813 | output.write_all(&serde_json::to_vec(value)?)?; |
| 1814 | output.sync_all()?; |
| 1815 | std::fs::rename(partial, path)?; |
| 1816 | #[cfg(unix)] |
| 1817 | std::fs::File::open(path.parent().unwrap())?.sync_all()?; |
| 1818 | Ok(()) |
| 1819 | } |
| 1820 | |
| 1821 | struct JoinControl { |
| 1822 | deadline: Instant, |
| 1823 | cancelled: AtomicBool, |
| 1824 | approving: AtomicBool, |
| 1825 | } |
| 1826 | |
| 1827 | impl JoinControl { |
| 1828 | fn new() -> Self { |
| 1829 | Self { |
| 1830 | deadline: Instant::now() + Duration::from_secs(30), |
| 1831 | cancelled: AtomicBool::new(false), |
| 1832 | approving: AtomicBool::new(false), |
| 1833 | } |
| 1834 | } |
| 1835 | } |
| 1836 | |
| 1837 | extern "C" fn live_join_wait(token: usize, approving: bool) -> bool { |
| 1838 | // SAFETY: join_live keeps its borrowed JoinControl alive until sb_live_join returns. |
| 1839 | let control = unsafe { &*(token as *const JoinControl) }; |
| 1840 | control.approving.store(approving, Ordering::Release); |
| 1841 | !control.cancelled.load(Ordering::Acquire) && Instant::now() < control.deadline |
| 1842 | } |
| 1843 | |
| 1844 | fn take_json(raw: *mut c_char) -> serde_json::Value { |
| 1845 | assert!(!raw.is_null()); |
| 1846 | let text = unsafe { CString::from_raw(raw) }; |
| 1847 | serde_json::from_slice(text.as_bytes()).unwrap() |
| 1848 | } |
| 1849 | |
| 1850 | fn join_live(code: &str, password: &str, relay: &str, control: &JoinControl) -> serde_json::Value { |
| 1851 | let code = CString::new(code).unwrap(); |
| 1852 | let password = CString::new(password).unwrap(); |
| 1853 | let relay = CString::new(relay).unwrap(); |
| 1854 | take_json(unsafe { |
| 1855 | library::sb_live_join( |
| 1856 | c"Mobile guest".as_ptr(), |
| 1857 | c"Owned test device".as_ptr(), |
| 1858 | code.as_ptr(), |
| 1859 | password.as_ptr(), |
| 1860 | relay.as_ptr(), |
| 1861 | false, |
| 1862 | live_join_wait, |
| 1863 | control as *const JoinControl as usize, |
| 1864 | ) |
| 1865 | }) |
| 1866 | } |
| 1867 | |
| 1868 | fn hosted_c( |
| 1869 | credential: &serde_json::Value, |
| 1870 | relay: &str, |
| 1871 | offline: bool, |
| 1872 | cache: &Path, |
| 1873 | ) -> Arc<Library> { |
| 1874 | let credential = CString::new(serde_json::to_vec(credential).unwrap()).unwrap(); |
| 1875 | let relay = CString::new(relay).unwrap(); |
| 1876 | let cache = CString::new(cache.to_str().unwrap()).unwrap(); |
| 1877 | let mut error = std::ptr::null_mut(); |
| 1878 | let raw = unsafe { |
| 1879 | library::sb_library_hosted( |
| 1880 | credential.as_ptr(), |
| 1881 | c"Mobile guest".as_ptr(), |
| 1882 | c"Owned test device".as_ptr(), |
| 1883 | relay.as_ptr(), |
| 1884 | false, |
| 1885 | offline, |
| 1886 | cache.as_ptr(), |
| 1887 | &mut error, |
| 1888 | ) |
| 1889 | }; |
| 1890 | if !error.is_null() { |
| 1891 | let error = unsafe { CString::from_raw(error) }; |
| 1892 | panic!("hosted open: {}", error.to_string_lossy()); |
| 1893 | } |
| 1894 | assert!(!raw.is_null()); |
| 1895 | // SAFETY: sb_library_hosted hands the caller one owned Arc reference. |
| 1896 | unsafe { Arc::from_raw(raw) } |
| 1897 | } |
| 1898 | |
| 1899 | #[test] |
| 1900 | fn live_join_cancels_approval_and_returns_private_per_device_credentials() { |
| 1901 | live_relay_child(); |
| 1902 | let directory = tempfile::tempdir().unwrap(); |
| 1903 | let (_relay, url) = LiveRelay::start( |
| 1904 | "tests::live_join_cancels_approval_and_returns_private_per_device_credentials", |
| 1905 | directory.path(), |
| 1906 | ); |
| 1907 | let root = directory.path().join("notebook"); |
| 1908 | let cache = directory.path().join("host-cache"); |
| 1909 | let creation = onestore::PageCreation::new(None, Some("Mobile fixture"), "Fixture").unwrap(); |
| 1910 | drop(notebook::session::Notebook::create(&root, &cache, 0, &creation).unwrap()); |
| 1911 | let mut sharing = notebook::live::share::Sharing::new("join password").unwrap(); |
| 1912 | sharing.approve = true; |
| 1913 | let host = live_host(&root, &cache, sharing, &url); |
| 1914 | assert!(eventually(|| host.code().is_some())); |
| 1915 | let code = host.code().unwrap(); |
| 1916 | assert_eq!( |
| 1917 | join_live("malformed", "", &url, &JoinControl::new())["refusal"], |
| 1918 | "malformed" |
| 1919 | ); |
| 1920 | assert_eq!( |
| 1921 | join_live(&code, "wrong password", &url, &JoinControl::new())["refusal"], |
| 1922 | "wrong" |
| 1923 | ); |
| 1924 | assert!(host.devices().is_empty()); |
| 1925 | for decision in ["cancel", "decline", "allow"] { |
| 1926 | let control = JoinControl::new(); |
| 1927 | let reply = std::thread::scope(|scope| { |
| 1928 | let joined = scope.spawn(|| join_live(&code, "join password", &url, &control)); |
| 1929 | assert!(eventually( |
| 1930 | || control.approving.load(Ordering::Acquire) && host.requests().len() == 1 |
| 1931 | )); |
| 1932 | match decision { |
| 1933 | "cancel" => control.cancelled.store(true, Ordering::Release), |
| 1934 | "decline" => host.decline(&host.requests()[0].peer), |
| 1935 | "allow" => host.allow(&host.requests()[0].peer).unwrap(), |
| 1936 | _ => unreachable!(), |
| 1937 | } |
| 1938 | joined.join().unwrap() |
| 1939 | }); |
| 1940 | match decision { |
| 1941 | "allow" => { |
| 1942 | let credential = &reply["credential"]; |
| 1943 | assert!(credential.is_object()); |
| 1944 | let persisted: notebook::live::share::Sharing = |
| 1945 | serde_json::from_slice(&std::fs::read(cache.join("sharing.json")).unwrap()) |
| 1946 | .unwrap(); |
| 1947 | assert!(credential["secret"] == serde_json::json!(persisted.members[0].secret)); |
| 1948 | assert!(credential["secret"] != serde_json::json!(persisted.secret)); |
| 1949 | assert!(credential.get("room").is_none()); |
| 1950 | assert!(credential.get("code").is_none()); |
| 1951 | assert!(credential.get("password").is_none()); |
| 1952 | assert_eq!(credential["protocol"], notebook::live::wire::VERSION); |
| 1953 | let kept = directory.path().join("joined.json"); |
| 1954 | keep_live_fixture(&kept, credential).unwrap(); |
| 1955 | let credential: serde_json::Value = |
| 1956 | serde_json::from_slice(&std::fs::read(kept).unwrap()).unwrap(); |
| 1957 | let library = hosted_c( |
| 1958 | &credential, |
| 1959 | &url, |
| 1960 | false, |
| 1961 | &directory.path().join("guest-cache"), |
| 1962 | ); |
| 1963 | assert!(eventually(|| take_json(library::sb_library_live_status( |
| 1964 | &library |
| 1965 | ))["state"] |
| 1966 | == "connected")); |
| 1967 | assert_eq!(library.tabs().unwrap().len(), 1); |
| 1968 | } |
| 1969 | _ => { |
| 1970 | assert_eq!( |
| 1971 | reply["refusal"], |
| 1972 | if decision == "cancel" { |
| 1973 | "cancelled" |
| 1974 | } else { |
| 1975 | "declined" |
| 1976 | } |
| 1977 | ); |
| 1978 | assert!(reply.get("credential").is_none()); |
| 1979 | assert!(host.devices().is_empty()); |
| 1980 | assert!(eventually(|| host.requests().is_empty())); |
| 1981 | } |
| 1982 | } |
| 1983 | } |
| 1984 | } |
| 1985 | |
| 1986 | #[test] |
| 1987 | fn live_guests_reopen_original_offline_edits_search_and_resume_closed_sections() { |
| 1988 | live_relay_child(); |
| 1989 | let directory = tempfile::tempdir().unwrap(); |
| 1990 | let (_relay, url) = LiveRelay::start( |
| 1991 | "tests::live_guests_reopen_original_offline_edits_search_and_resume_closed_sections", |
| 1992 | directory.path(), |
| 1993 | ); |
| 1994 | let root = directory.path().join("notebook"); |
| 1995 | std::fs::create_dir_all(&root).unwrap(); |
| 1996 | for (file, text) in [ |
| 1997 | ("Read.one", "Original mobile text"), |
| 1998 | ("Other.one", "Closed section text"), |
| 1999 | ] { |
| 2000 | std::fs::write( |
| 2001 | root.join(file), |
| 2002 | onestore::create_section(file, text, "Fixture").unwrap(), |
| 2003 | ) |
| 2004 | .unwrap(); |
| 2005 | } |
| 2006 | let sealed = onestore::create_section("Sealed.one", "Protected fixture", "Fixture").unwrap(); |
| 2007 | std::fs::write( |
| 2008 | root.join("Sealed.one"), |
| 2009 | onestore::protected::rekey( |
| 2010 | &sealed, |
| 2011 | None, |
| 2012 | Some(&onestore::protected::Key::new("section password").unwrap()), |
| 2013 | ) |
| 2014 | .unwrap(), |
| 2015 | ) |
| 2016 | .unwrap(); |
| 2017 | let sealed = std::fs::read(root.join("Sealed.one")).unwrap(); |
| 2018 | let host_cache = directory.path().join("host-cache"); |
| 2019 | let host = live_host( |
| 2020 | &root, |
| 2021 | &host_cache, |
| 2022 | notebook::live::share::Sharing::new("").unwrap(), |
| 2023 | &url, |
| 2024 | ); |
| 2025 | assert!(eventually(|| host.code().is_some())); |
| 2026 | let joined = join_live(&host.code().unwrap(), "", &url, &JoinControl::new()); |
| 2027 | let kept = directory.path().join("joined.json"); |
| 2028 | assert!(joined["credential"].is_object()); |
| 2029 | keep_live_fixture(&kept, &joined["credential"]).unwrap(); |
| 2030 | let credential: serde_json::Value = |
| 2031 | serde_json::from_slice(&std::fs::read(&kept).unwrap()).unwrap(); |
| 2032 | let credential = &credential; |
| 2033 | let cache = directory.path().join("guest-cache"); |
| 2034 | let library = hosted_c(credential, &url, false, &cache); |
| 2035 | let tabs = library.tabs().unwrap(); |
| 2036 | assert!( |
| 2037 | tabs.iter() |
| 2038 | .any(|tab| tab.path == "Sealed.one" && tab.locked && !tab.readable) |
| 2039 | ); |
| 2040 | let share: [u8; 16] = serde_json::from_value(credential["share"].clone()).unwrap(); |
| 2041 | let copies = notebook::location::folder(&cache, &notebook::live::share::location(&share)); |
| 2042 | let replicas = || { |
| 2043 | let mut paths: Vec<_> = std::fs::read_dir(&copies) |
| 2044 | .unwrap() |
| 2045 | .map(|entry| entry.unwrap().path()) |
| 2046 | .filter(|path| { |
| 2047 | path.extension() |
| 2048 | .is_some_and(|extension| extension == "sqlite") |
| 2049 | }) |
| 2050 | .collect(); |
| 2051 | paths.sort(); |
| 2052 | paths |
| 2053 | }; |
| 2054 | assert!(eventually(|| copies.exists() && replicas().len() == 2)); |
| 2055 | let original_replicas = replicas(); |
| 2056 | assert!(eventually(|| library |
| 2057 | .sync_status() |
| 2058 | .iter() |
| 2059 | .filter(|sync| sync.state == SyncState::UpToDate as u8) |
| 2060 | .count() |
| 2061 | == 2)); |
| 2062 | let sharing = host.sharing(); |
| 2063 | drop(host); |
| 2064 | assert!(eventually(|| take_json(library::sb_library_live_status( |
| 2065 | &library |
| 2066 | ))["state"] |
| 2067 | != "connected")); |
| 2068 | sb_library_set_offline(&library, true); |
| 2069 | let section = open(&library, "Read.one"); |
| 2070 | let space = section.shared.section.pages().unwrap()[0].0; |
| 2071 | let page = section.shared.section.page(space).unwrap(); |
| 2072 | let text = page |
| 2073 | .objects |
| 2074 | .iter() |
| 2075 | .find_map(|object| match object { |
| 2076 | onestore::page::PageObject::Outline(outline) => { |
| 2077 | outline.paragraphs.iter().find_map(|paragraph| { |
| 2078 | paragraph |
| 2079 | .text() |
| 2080 | .filter(|text| text.text.text().contains("Original mobile text")) |
| 2081 | .map(|text| text.id) |
| 2082 | }) |
| 2083 | } |
| 2084 | _ => None, |
| 2085 | }) |
| 2086 | .unwrap(); |
| 2087 | section |
| 2088 | .shared |
| 2089 | .apply(Edit { |
| 2090 | at: library::filetime(), |
| 2091 | ops: vec![Op::Page { |
| 2092 | space, |
| 2093 | op: PageOp::Text { |
| 2094 | text, |
| 2095 | range: 0..0, |
| 2096 | with: "OFFLINE_GUEST_TOKEN ".into(), |
| 2097 | }, |
| 2098 | }], |
| 2099 | }) |
| 2100 | .unwrap(); |
| 2101 | section.shared.section.written().unwrap(); |
| 2102 | let pending = section.shared.section.pending().unwrap(); |
| 2103 | assert_eq!(pending.len(), 1); |
| 2104 | let identity = section.shared.section.replica().identity().unwrap(); |
| 2105 | let shared = Arc::clone(&section.shared); |
| 2106 | drop((section, library)); |
| 2107 | Arc::into_inner(shared).unwrap().section.close().unwrap(); |
| 2108 | |
| 2109 | let reopened: serde_json::Value = |
| 2110 | serde_json::from_slice(&std::fs::read(&kept).unwrap()).unwrap(); |
| 2111 | let library = hosted_c(&reopened, &url, true, &cache); |
| 2112 | assert!( |
| 2113 | library |
| 2114 | .tabs() |
| 2115 | .unwrap() |
| 2116 | .iter() |
| 2117 | .any(|tab| tab.path == "Read.one" && tab.readable) |
| 2118 | ); |
| 2119 | assert_eq!(replicas(), original_replicas); |
| 2120 | let section = open(&library, "Read.one"); |
| 2121 | assert_eq!( |
| 2122 | section.shared.section.replica().identity().unwrap(), |
| 2123 | identity |
| 2124 | ); |
| 2125 | assert_eq!(section.shared.section.pending().unwrap(), pending); |
| 2126 | assert!( |
| 2127 | canvas::search::page_text(&section.shared.section.page(space).unwrap()) |
| 2128 | .contains("OFFLINE_GUEST_TOKEN") |
| 2129 | ); |
| 2130 | let shared = Arc::clone(&section.shared); |
| 2131 | drop(section); |
| 2132 | Arc::into_inner(shared).unwrap().section.close().unwrap(); |
| 2133 | assert_eq!( |
| 2134 | library::search(&[&library], None, "OFFLINE_GUEST_TOKEN") |
| 2135 | .unwrap() |
| 2136 | .len(), |
| 2137 | 1 |
| 2138 | ); |
| 2139 | assert_eq!(std::fs::read(root.join("Sealed.one")).unwrap(), sealed); |
| 2140 | assert!(!stored_text(&root.join("Read.one"), space).contains("OFFLINE_GUEST_TOKEN")); |
| 2141 | let host = live_host(&root, &host_cache, sharing, &url); |
| 2142 | assert!(eventually(|| take_json(library::sb_library_live_status( |
| 2143 | &library |
| 2144 | ))["state"] |
| 2145 | == "connected")); |
| 2146 | let original = std::fs::read(root.join("Read.one")).unwrap(); |
| 2147 | drop(library); |
| 2148 | let library = hosted_c(&reopened, &url, true, &cache); |
| 2149 | assert!(eventually(|| take_json(library::sb_library_live_status( |
| 2150 | &library |
| 2151 | ))["state"] |
| 2152 | == "connected")); |
| 2153 | assert_eq!(replicas(), original_replicas); |
| 2154 | for _ in 0..10 { |
| 2155 | let retained: Vec<_> = original_replicas |
| 2156 | .iter() |
| 2157 | .flat_map(|path| notebook::Replica::open(path).unwrap().pending().unwrap()) |
| 2158 | .collect(); |
| 2159 | assert_eq!(retained, pending); |
| 2160 | assert_eq!(std::fs::read(root.join("Read.one")).unwrap(), original); |
| 2161 | std::thread::sleep(Duration::from_millis(20)); |
| 2162 | } |
| 2163 | // Closed-section search cannot override Work Offline while the host is reachable. |
| 2164 | for _ in 0..32 { |
| 2165 | assert_eq!( |
| 2166 | library::search(&[&library], None, "OFFLINE_GUEST_TOKEN") |
| 2167 | .unwrap() |
| 2168 | .len(), |
| 2169 | 1 |
| 2170 | ); |
| 2171 | assert_eq!(std::fs::read(root.join("Read.one")).unwrap(), original); |
| 2172 | } |
| 2173 | let retained: Vec<_> = original_replicas |
| 2174 | .iter() |
| 2175 | .flat_map(|path| notebook::Replica::open(path).unwrap().pending().unwrap()) |
| 2176 | .collect(); |
| 2177 | assert_eq!(retained, pending); |
| 2178 | let section = open(&library, "Read.one"); |
| 2179 | assert_eq!( |
| 2180 | section.shared.section.replica().identity().unwrap(), |
| 2181 | identity |
| 2182 | ); |
| 2183 | for _ in 0..10 { |
| 2184 | assert_eq!(section.shared.section.pending().unwrap(), pending); |
| 2185 | assert_eq!(std::fs::read(root.join("Read.one")).unwrap(), original); |
| 2186 | std::thread::sleep(Duration::from_millis(20)); |
| 2187 | } |
| 2188 | assert!(library.sync_status().iter().any(|sync| sync.queued == 1)); |
| 2189 | sb_library_sync_now(&library); |
| 2190 | assert!(eventually(|| library |
| 2191 | .sync_status() |
| 2192 | .iter() |
| 2193 | .all(|sync| sync.queued == 0))); |
| 2194 | assert_eq!( |
| 2195 | stored_text(&root.join("Read.one"), space) |
| 2196 | .matches("OFFLINE_GUEST_TOKEN") |
| 2197 | .count(), |
| 2198 | 1 |
| 2199 | ); |
| 2200 | assert_eq!(std::fs::read(root.join("Sealed.one")).unwrap(), sealed); |
| 2201 | assert!(section.shared.section.pending().unwrap().is_empty()); |
| 2202 | let shared = Arc::clone(&section.shared); |
| 2203 | drop(section); |
| 2204 | Arc::into_inner(shared).unwrap().section.close().unwrap(); |
| 2205 | sb_library_set_offline(&library, false); |
| 2206 | let other_join = join_live(&host.code().unwrap(), "", &url, &JoinControl::new()); |
| 2207 | let other_kept = directory.path().join("other-joined.json"); |
| 2208 | keep_live_fixture(&other_kept, &other_join["credential"]).unwrap(); |
| 2209 | let other_credential: serde_json::Value = |
| 2210 | serde_json::from_slice(&std::fs::read(other_kept).unwrap()).unwrap(); |
| 2211 | let other = hosted_c( |
| 2212 | &other_credential, |
| 2213 | &url, |
| 2214 | false, |
| 2215 | &directory.path().join("other-guest-cache"), |
| 2216 | ); |
| 2217 | let secret: [u8; 16] = serde_json::from_value(credential["secret"].clone()).unwrap(); |
| 2218 | host.remove(&secret).unwrap(); |
| 2219 | assert!(eventually(|| take_json(library::sb_library_live_status( |
| 2220 | &library |
| 2221 | ))["state"] |
| 2222 | == "removed")); |
| 2223 | let unaffected = open(&other, "Read.one"); |
| 2224 | unaffected |
| 2225 | .shared |
| 2226 | .apply(Edit { |
| 2227 | at: library::filetime(), |
| 2228 | ops: vec![Op::Page { |
| 2229 | space, |
| 2230 | op: PageOp::Text { |
| 2231 | text, |
| 2232 | range: 0..0, |
| 2233 | with: "OTHER_GUEST_TOKEN ".into(), |
| 2234 | }, |
| 2235 | }], |
| 2236 | }) |
| 2237 | .unwrap(); |
| 2238 | assert!(unaffected.flush(Duration::from_secs(20))); |
| 2239 | assert_eq!( |
| 2240 | stored_text(&root.join("Read.one"), space) |
| 2241 | .matches("OTHER_GUEST_TOKEN") |
| 2242 | .count(), |
| 2243 | 1 |
| 2244 | ); |
| 2245 | sb_library_set_offline(&library, true); |
| 2246 | let section = open(&library, "Read.one"); |
| 2247 | section |
| 2248 | .shared |
| 2249 | .apply(Edit { |
| 2250 | at: library::filetime(), |
| 2251 | ops: vec![Op::Page { |
| 2252 | space, |
| 2253 | op: PageOp::Text { |
| 2254 | text, |
| 2255 | range: 0..0, |
| 2256 | with: "REMOVED_GUEST_TOKEN ".into(), |
| 2257 | }, |
| 2258 | }], |
| 2259 | }) |
| 2260 | .unwrap(); |
| 2261 | section.shared.section.written().unwrap(); |
| 2262 | let pending = section.shared.section.pending().unwrap(); |
| 2263 | sb_library_sync_now(&library); |
| 2264 | assert!(eventually(|| library.sync_status().iter().any(|sync| sync |
| 2265 | .path |
| 2266 | == "Read.one" |
| 2267 | && sync.state == SyncState::NotConnected as u8 |
| 2268 | && sync.queued == 1))); |
| 2269 | assert_eq!(section.shared.section.pending().unwrap(), pending); |
| 2270 | let shared = Arc::clone(&section.shared); |
| 2271 | drop((section, library)); |
| 2272 | Arc::into_inner(shared).unwrap().section.close().unwrap(); |
| 2273 | let reopened: serde_json::Value = |
| 2274 | serde_json::from_slice(&std::fs::read(&kept).unwrap()).unwrap(); |
| 2275 | let library = hosted_c(&reopened, &url, true, &cache); |
| 2276 | let section = open(&library, "Read.one"); |
| 2277 | assert_eq!(section.shared.section.pending().unwrap(), pending); |
| 2278 | assert!( |
| 2279 | canvas::search::page_text(&section.shared.section.page(space).unwrap()) |
| 2280 | .contains("REMOVED_GUEST_TOKEN") |
| 2281 | ); |
| 2282 | assert!(!stored_text(&root.join("Read.one"), space).contains("REMOVED_GUEST_TOKEN")); |
| 2283 | assert_eq!(std::fs::read(root.join("Sealed.one")).unwrap(), sealed); |
| 2284 | } |
| 2285 | |
| 2286 | #[test] |
| 2287 | fn hosted_credentials_reject_unknown_versions_without_echoing_secret_input() { |
| 2288 | let directory = tempfile::tempdir().unwrap(); |
| 2289 | let cache = directory.path().join("must-not-be-created"); |
| 2290 | let cache = CString::new(cache.to_str().unwrap()).unwrap(); |
| 2291 | let wrong_version = serde_json::json!({ |
| 2292 | "share": ([1u8; 16]), "secret": ([2u8; 16]), |
| 2293 | "protocol": notebook::live::wire::VERSION + 1, |
| 2294 | "notebook": "Fixture", "host": "Host", |
| 2295 | }) |
| 2296 | .to_string(); |
| 2297 | for input in ["{SECRET_MUST_NOT_BE_ECHOED", wrong_version.as_str()] { |
| 2298 | let input = CString::new(input).unwrap(); |
| 2299 | let mut error = std::ptr::null_mut(); |
| 2300 | let opened = unsafe { |
| 2301 | library::sb_library_hosted( |
| 2302 | input.as_ptr(), |
| 2303 | c"Guest".as_ptr(), |
| 2304 | std::ptr::null(), |
| 2305 | std::ptr::null(), |
| 2306 | false, |
| 2307 | true, |
| 2308 | cache.as_ptr(), |
| 2309 | &mut error, |
| 2310 | ) |
| 2311 | }; |
| 2312 | assert!(opened.is_null()); |
| 2313 | assert!(!error.is_null()); |
| 2314 | let error = unsafe { CString::from_raw(error) }; |
| 2315 | assert!( |
| 2316 | !error |
| 2317 | .to_bytes() |
| 2318 | .windows(b"SECRET_MUST_NOT_BE_ECHOED".len()) |
| 2319 | .any(|bytes| bytes == b"SECRET_MUST_NOT_BE_ECHOED") |
| 2320 | ); |
| 2321 | } |
| 2322 | assert!(!Path::new(cache.to_str().unwrap()).exists()); |
| 2323 | } |