diff --git a/crates/ely_app/src/services/local_state.rs b/crates/ely_app/src/services/local_state.rs new file mode 100644 index 0000000..3c2aac2 --- /dev/null +++ b/crates/ely_app/src/services/local_state.rs @@ -0,0 +1,81 @@ +//! On-disk browser state for one standard profile: +//! `//local-state.json`, published +//! atomically so a crash mid-write can never truncate the previous state. + +use std::{ + fs, io, + io::Write, + path::{Path, PathBuf}, +}; + +use ely_domain::ProfileId; + +const LOCAL_STATE_FILE: &str = "local-state.json"; + +pub(crate) fn local_state_path(profile_data_root: &Path, profile_id: &ProfileId) -> PathBuf { + profile_data_root.join(profile_id.as_str()).join(LOCAL_STATE_FILE) +} + +pub(crate) fn save_local_state(path: &Path, bytes: &[u8]) -> io::Result<()> { + let directory = path + .parent() + .ok_or_else(|| io::Error::other("local state path has no parent directory"))?; + fs::create_dir_all(directory)?; + let temporary = path.with_extension("json.tmp"); + { + let mut file = fs::File::create(&temporary)?; + file.write_all(bytes)?; + file.sync_all()?; + } + fs::rename(&temporary, path) +} + +/// Missing file is a normal first launch. Read or parse failures stay with +/// the caller so a broken restore is loud, quarantined, and recoverable. +pub(crate) fn load_local_state(path: &Path) -> io::Result>> { + match fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } +} + +pub(crate) fn quarantine_local_state(path: &Path) -> io::Result { + let quarantined = path.with_extension("json.corrupt"); + fs::rename(path, &quarantined)?; + Ok(quarantined) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn save_then_load_round_trips() -> Result<(), Box> { + let directory = tempfile::tempdir()?; + let profile_id = ProfileId::new(); + let path = local_state_path(directory.path(), &profile_id); + + assert_eq!(load_local_state(&path)?, None); + save_local_state(&path, b"{\"local_rev\":1}")?; + assert_eq!(load_local_state(&path)?, Some(b"{\"local_rev\":1}".to_vec())); + + save_local_state(&path, b"{\"local_rev\":2}")?; + assert_eq!(load_local_state(&path)?, Some(b"{\"local_rev\":2}".to_vec())); + Ok(()) + } + + #[test] + fn quarantine_moves_the_corrupt_file_aside() -> Result<(), Box> { + let directory = tempfile::tempdir()?; + let profile_id = ProfileId::new(); + let path = local_state_path(directory.path(), &profile_id); + save_local_state(&path, b"broken")?; + + let quarantined = quarantine_local_state(&path)?; + + assert_eq!(load_local_state(&path)?, None); + assert_eq!(std::fs::read(quarantined)?, b"broken"); + Ok(()) + } +} diff --git a/crates/ely_app/src/services/mod.rs b/crates/ely_app/src/services/mod.rs index f7501a4..eb6d82c 100644 --- a/crates/ely_app/src/services/mod.rs +++ b/crates/ely_app/src/services/mod.rs @@ -5,6 +5,7 @@ pub mod http_downloads; pub(crate) mod iosurface_mach; #[cfg(target_os = "macos")] pub(crate) mod iosurface_metal; +pub(crate) mod local_state; pub mod plugin_package_store; pub mod plugin_packages; pub mod plugin_signatures; diff --git a/crates/ely_app/src/shell/local_persistence.rs b/crates/ely_app/src/shell/local_persistence.rs new file mode 100644 index 0000000..c35bc70 --- /dev/null +++ b/crates/ely_app/src/shell/local_persistence.rs @@ -0,0 +1,164 @@ +//! Shell glue for on-disk browser state: restore at construction, a +//! debounced save after every mutation that schedules a sync upload, and +//! a final synchronous save when the app quits. + +use std::{ + path::{Path, PathBuf}, + time::Duration, +}; + +use ely_browser_core::BrowserCore; +use ely_domain::ProfileId; +use gpui::{Context, Subscription, Timer}; + +use super::{ElyShell, ShellState}; +use crate::services::local_state::{ + load_local_state, local_state_path, quarantine_local_state, save_local_state, +}; +use crate::services::servo_profile_data::default_profile_data_root; + +const LOCAL_STATE_SAVE_DEBOUNCE: Duration = Duration::from_secs(1); + +pub(super) fn resolve_local_state_path(default_profile_id: Option<&ProfileId>) -> Option { + // Harness tests build the real shell; persistence stays inert there so + // tests never read or write the developer's actual profile. + if cfg!(test) { + return None; + } + let profile_id = default_profile_id?; + let root = default_profile_data_root()?; + Some(local_state_path(&root, profile_id)) +} + +/// Restore persisted state into a freshly constructed core. A corrupt or +/// unreadable file is quarantined loudly instead of silently replaced, so +/// the previous state stays recoverable for diagnosis. +pub(super) fn restore_local_state(core: &mut BrowserCore, path: &Path) { + let bytes = match load_local_state(path) { + Ok(Some(bytes)) => bytes, + Ok(None) => return, + Err(error) => { + tracing::error!( + target: "ely::local_state", + error = %error, + path = %path.display(), + "local state read failed; starting from defaults", + ); + return; + } + }; + match core.apply_local_state_bytes(&bytes) { + Ok(()) => { + tracing::info!( + target: "ely::local_state", + path = %path.display(), + bytes = bytes.len(), + "local state restored", + ); + } + Err(error) => { + tracing::error!( + target: "ely::local_state", + error = %error, + path = %path.display(), + "local state restore failed; quarantining the file", + ); + match quarantine_local_state(path) { + Ok(quarantined) => tracing::warn!( + target: "ely::local_state", + path = %quarantined.display(), + "corrupt local state preserved for diagnosis", + ), + Err(error) => tracing::error!( + target: "ely::local_state", + error = %error, + "local state quarantine failed", + ), + } + } + } +} + +pub(super) fn register_quit_save(cx: &mut Context) -> Subscription { + cx.on_app_quit(|shell, _cx| { + shell.save_local_state_blocking(); + async {} + }) +} + +impl ElyShell { + /// Every mutation that schedules a cloud sync upload also schedules a + /// local save; the debounce collapses bursts into one write. + pub(crate) fn schedule_local_state_save(&mut self, cx: &mut Context) { + if self.local_state_path.is_none() || self.local_state_save_scheduled { + return; + } + self.local_state_save_scheduled = true; + cx.spawn(async move |shell, cx| { + Timer::after(LOCAL_STATE_SAVE_DEBOUNCE).await; + let _ = shell.update(cx, |shell, _| { + shell.local_state_save_scheduled = false; + shell.save_local_state_in_background(); + }); + }) + .detach(); + } + + fn save_local_state_in_background(&self) { + let Some((path, bytes)) = self.build_local_state_write() else { + return; + }; + std::thread::Builder::new() + .name("ely-local-state-save".to_string()) + .spawn(move || { + if let Err(error) = save_local_state(&path, &bytes) { + tracing::error!( + target: "ely::local_state", + error = %error, + path = %path.display(), + "local state save failed", + ); + } + }) + .map(|_| ()) + .unwrap_or_else(|error| { + tracing::warn!( + target: "ely::local_state", + error = %error, + "spawn local state save failed", + ); + }); + } + + pub(super) fn save_local_state_blocking(&self) { + let Some((path, bytes)) = self.build_local_state_write() else { + return; + }; + if let Err(error) = save_local_state(&path, &bytes) { + tracing::error!( + target: "ely::local_state", + error = %error, + path = %path.display(), + "local state save on quit failed", + ); + } + } + + fn build_local_state_write(&self) -> Option<(PathBuf, Vec)> { + let path = self.local_state_path.clone()?; + let ShellState::Ready(core) = &self.state else { + return None; + }; + match core.build_local_state_bytes() { + Ok(bytes) => Some((path, bytes)), + Err(error) => { + tracing::error!( + target: "ely::local_state", + error = %error, + "local state serialization failed", + ); + None + } + } + } +} diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index 67efe3d..a762a8a 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -12,6 +12,7 @@ mod focus; mod history; mod internal_pages; mod local_data_files; +mod local_persistence; mod navigation; mod notes; mod plugins; @@ -130,8 +131,11 @@ pub struct ElyShell { pub(crate) auth_email_input: Entity, pub(crate) auth_otp_input: Entity, pub(crate) auth_flow_phase: auth::AuthFlowPhase, + pub(crate) local_state_path: Option, + pub(crate) local_state_save_scheduled: bool, _command_subscription: Subscription, _translucency_subscription: Subscription, + _quit_save_subscription: Option, } impl ElyShell { @@ -225,10 +229,17 @@ impl ElyShell { }, ); + let local_state_path = + local_persistence::resolve_local_state_path(default_profile_id.as_ref()); let state = match config .and_then(|config| BrowserCore::new(config).map_err(|error| error.to_string())) { - Ok(core) => ShellState::Ready(Box::new(core)), + Ok(mut core) => { + if let Some(path) = &local_state_path { + local_persistence::restore_local_state(&mut core, path); + } + ShellState::Ready(Box::new(core)) + } Err(error) => ShellState::StartupError(error), }; @@ -280,6 +291,8 @@ impl ElyShell { authenticated_operation_gate: AuthenticatedOperationGate::open(), auth_flow_barrier: None, sign_out_phases: std::collections::HashMap::new(), + local_state_path, + local_state_save_scheduled: false, sync_upload_scheduled: false, sync_upload_in_flight: false, sync_upload_pending: false, @@ -293,7 +306,9 @@ impl ElyShell { auth_flow_phase: auth::AuthFlowPhase::Idle, _command_subscription: command_subscription, _translucency_subscription: translucency_subscription, + _quit_save_subscription: None, }; + shell._quit_save_subscription = Some(local_persistence::register_quit_save(cx)); let should_run_initial_sync = shell.probe_initial_sync_state(); if should_run_initial_sync { shell.trigger_cloud_sync_upload(); diff --git a/crates/ely_app/src/shell/sync_state.rs b/crates/ely_app/src/shell/sync_state.rs index e96227e..16e3ad3 100644 --- a/crates/ely_app/src/shell/sync_state.rs +++ b/crates/ely_app/src/shell/sync_state.rs @@ -65,6 +65,7 @@ pub(crate) const fn sync_platform_label() -> &'static str { impl ElyShell { pub(crate) fn schedule_cloud_sync_upload(&mut self, cx: &mut Context) { + self.schedule_local_state_save(cx); self.reconcile_active_sync_profile(); if !self.can_schedule_cloud_sync_upload() { return; diff --git a/crates/ely_browser_core/src/error.rs b/crates/ely_browser_core/src/error.rs index c4ac91a..73bb917 100644 --- a/crates/ely_browser_core/src/error.rs +++ b/crates/ely_browser_core/src/error.rs @@ -27,6 +27,9 @@ pub enum CoreError { #[error("invalid .elydata package: {reason}")] InvalidLocalDataPackage { reason: String }, + #[error("local state persistence failed: {reason}")] + LocalState { reason: String }, + #[error("trashed space not found: {id}")] TrashedSpaceNotFound { id: SpaceId }, diff --git a/crates/ely_browser_core/src/lib.rs b/crates/ely_browser_core/src/lib.rs index 861be42..fb24089 100644 --- a/crates/ely_browser_core/src/lib.rs +++ b/crates/ely_browser_core/src/lib.rs @@ -1,4 +1,5 @@ mod error; +mod local_state; mod navigation; mod state; mod sync_engine; diff --git a/crates/ely_browser_core/src/local_state.rs b/crates/ely_browser_core/src/local_state.rs new file mode 100644 index 0000000..f2ea4d6 --- /dev/null +++ b/crates/ely_browser_core/src/local_state.rs @@ -0,0 +1,113 @@ +//! Versioned on-disk browser state. The wire shape reuses the sync +//! snapshot records and appliers — one canonical serialization with two +//! consumers — but visibility is local: cloud sync policies never +//! reduce what survives a restart, and Private-profile data never +//! reaches disk. + +use serde::{Deserialize, Serialize}; + +use crate::{ + CoreError, + state::BrowserCore, + sync_records::{ + BookmarkSyncRecord, HistorySyncRecord, NoteSyncRecord, PluginSettingsSyncRecord, + ProfileSyncRecord, ReadingListSyncRecord, SNAPSHOT_SCHEMA_REV, SitePermissionSyncRecord, + SpaceSyncRecord, SyncSnapshotBody, TabSyncRecord, + }, +}; + +pub(crate) const LOCAL_STATE_REV: u32 = 1; + +#[derive(Serialize, Deserialize)] +struct LocalStateDocument { + local_rev: u32, + body: SyncSnapshotBody, +} + +impl BrowserCore { + pub fn build_local_state_bytes(&self) -> Result, CoreError> { + let document = + LocalStateDocument { local_rev: LOCAL_STATE_REV, body: local_body_from_core(self) }; + serde_json::to_vec(&document) + .map_err(|error| CoreError::LocalState { reason: error.to_string() }) + } + + pub fn apply_local_state_bytes(&mut self, bytes: &[u8]) -> Result<(), CoreError> { + let document: LocalStateDocument = serde_json::from_slice(bytes) + .map_err(|error| CoreError::LocalState { reason: error.to_string() })?; + if document.local_rev != LOCAL_STATE_REV { + return Err(CoreError::LocalState { + reason: format!("unsupported local_rev {}", document.local_rev), + }); + } + if document.body.schema_rev != SNAPSHOT_SCHEMA_REV { + return Err(CoreError::LocalState { + reason: format!("unsupported schema_rev {}", document.body.schema_rev), + }); + } + self.apply_sync_snapshot_body(document.body) + .map_err(|error| CoreError::LocalState { reason: error.to_string() })?; + Ok(()) + } +} + +fn local_body_from_core(core: &BrowserCore) -> SyncSnapshotBody { + SyncSnapshotBody { + schema_rev: SNAPSHOT_SCHEMA_REV, + profiles: core + .visible_profiles_for_local() + .into_iter() + .map(ProfileSyncRecord::from_profile) + .collect(), + spaces: core + .visible_spaces_for_local() + .into_iter() + .map(SpaceSyncRecord::from_space) + .collect(), + bookmarks: core + .visible_bookmarks_for_local() + .into_iter() + .map(|entry| { + BookmarkSyncRecord::from_entry(entry, core.sync_space_name_for(entry.space_id())) + }) + .collect(), + tabs: core + .visible_tabs_for_local() + .into_iter() + .map(|entry| { + TabSyncRecord::from_entry(entry, core.sync_space_name_for(entry.space_id())) + }) + .collect(), + notes: core + .visible_notes_for_local() + .into_iter() + .map(|entry| { + NoteSyncRecord::from_entry(entry, core.sync_space_name_for(entry.space_id())) + }) + .collect(), + reading_list: core + .visible_reading_list_for_local() + .into_iter() + .map(|entry| { + ReadingListSyncRecord::from_entry(entry, core.sync_space_name_for(entry.space_id())) + }) + .collect(), + site_permissions: core + .visible_site_permissions_for_local() + .into_iter() + .map(SitePermissionSyncRecord::from_entry) + .collect(), + history: core + .visible_history_for_local() + .into_iter() + .map(|entry| { + HistorySyncRecord::from_entry(entry, core.sync_space_name_for(entry.space_id())) + }) + .collect(), + plugin_settings: core + .visible_plugin_settings_for_local() + .into_iter() + .map(PluginSettingsSyncRecord::from_plugin) + .collect(), + } +} diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index 8261c83..c5658f3 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -18,6 +18,7 @@ mod downloads; mod history; mod local_data_export_records; mod local_data_exports; +mod local_visibility; mod notes; mod plugins; mod privacy; diff --git a/crates/ely_browser_core/src/state/local_visibility.rs b/crates/ely_browser_core/src/state/local_visibility.rs new file mode 100644 index 0000000..c904d7d --- /dev/null +++ b/crates/ely_browser_core/src/state/local_visibility.rs @@ -0,0 +1,70 @@ +//! Local persistence sees more than cloud sync: pausing cloud sync or +//! disabling a tab's sync must never reduce what survives a restart. +//! Only Private-profile data stays out of the on-disk state. + +use ely_domain::{ + BookmarkEntry, BrowserTab, HistoryEntry, NoteEntry, Profile, ProfileId, ProfileKind, + ReadingListEntry, SitePermissionEntry, Space, +}; + +use super::BrowserCore; +use crate::state::InstalledPlugin; + +impl BrowserCore { + fn profile_persists_locally(&self, profile_id: &ProfileId) -> bool { + self.profiles + .iter() + .any(|profile| profile.id() == profile_id && profile.kind() != &ProfileKind::Private) + } + + pub(crate) fn visible_profiles_for_local(&self) -> Vec<&Profile> { + self.profiles.iter().filter(|profile| profile.kind() != &ProfileKind::Private).collect() + } + + pub(crate) fn visible_spaces_for_local(&self) -> Vec<&Space> { + self.spaces.iter().collect() + } + + pub(crate) fn visible_tabs_for_local(&self) -> Vec<&BrowserTab> { + self.tabs.iter().filter(|tab| self.profile_persists_locally(tab.profile_id())).collect() + } + + pub(crate) fn visible_bookmarks_for_local(&self) -> Vec<&BookmarkEntry> { + self.bookmarks + .iter() + .filter(|entry| self.profile_persists_locally(entry.profile_id())) + .collect() + } + + pub(crate) fn visible_notes_for_local(&self) -> Vec<&NoteEntry> { + self.notes + .iter() + .filter(|entry| self.profile_persists_locally(entry.profile_id())) + .collect() + } + + pub(crate) fn visible_reading_list_for_local(&self) -> Vec<&ReadingListEntry> { + self.reading_list + .iter() + .filter(|entry| self.profile_persists_locally(entry.profile_id())) + .collect() + } + + pub(crate) fn visible_site_permissions_for_local(&self) -> Vec<&SitePermissionEntry> { + self.site_permissions + .iter() + .filter(|entry| self.profile_persists_locally(entry.profile_id())) + .collect() + } + + pub(crate) fn visible_history_for_local(&self) -> Vec<&HistoryEntry> { + self.history_entries + .iter() + .filter(|entry| self.profile_persists_locally(entry.profile_id())) + .collect() + } + + pub(crate) fn visible_plugin_settings_for_local(&self) -> Vec<&InstalledPlugin> { + self.installed_plugins.iter().collect() + } +} diff --git a/crates/ely_browser_core/src/sync_records.rs b/crates/ely_browser_core/src/sync_records.rs index 0eb249c..dcc6f03 100644 --- a/crates/ely_browser_core/src/sync_records.rs +++ b/crates/ely_browser_core/src/sync_records.rs @@ -113,7 +113,7 @@ pub(crate) struct ProfileSyncRecord { } impl ProfileSyncRecord { - fn from_profile(profile: &Profile) -> Self { + pub(crate) fn from_profile(profile: &Profile) -> Self { Self { id: profile.id().as_str().to_string(), name: profile.name().to_string(), @@ -189,7 +189,7 @@ pub(crate) struct SpaceSyncRecord { } impl SpaceSyncRecord { - fn from_space(space: &Space) -> Self { + pub(crate) fn from_space(space: &Space) -> Self { Self { id: space.id().as_str().to_string(), name: space.name().to_string(), @@ -246,7 +246,7 @@ pub(crate) struct BookmarkSyncRecord { } impl BookmarkSyncRecord { - fn from_entry(entry: &BookmarkEntry, space_name: Option) -> Self { + pub(crate) fn from_entry(entry: &BookmarkEntry, space_name: Option) -> Self { Self { id: entry.id().as_str().to_string(), title: entry.title().to_string(), @@ -285,7 +285,7 @@ pub(crate) struct TabSyncRecord { } impl TabSyncRecord { - fn from_entry(entry: &BrowserTab, space_name: Option) -> Self { + pub(crate) fn from_entry(entry: &BrowserTab, space_name: Option) -> Self { Self { id: entry.id().as_str().to_string(), title: entry.title().to_string(), @@ -320,7 +320,7 @@ pub(crate) struct NoteSyncRecord { } impl NoteSyncRecord { - fn from_entry(entry: &NoteEntry, space_name: Option) -> Self { + pub(crate) fn from_entry(entry: &NoteEntry, space_name: Option) -> Self { Self { id: entry.id().as_str().to_string(), profile_id: entry.profile_id().as_str().to_string(), @@ -366,7 +366,7 @@ pub(crate) struct ReadingListSyncRecord { } impl ReadingListSyncRecord { - fn from_entry(entry: &ReadingListEntry, space_name: Option) -> Self { + pub(crate) fn from_entry(entry: &ReadingListEntry, space_name: Option) -> Self { Self { id: entry.id().as_str().to_string(), profile_id: entry.profile_id().as_str().to_string(), @@ -407,7 +407,7 @@ pub(crate) struct SitePermissionSyncRecord { } impl SitePermissionSyncRecord { - fn from_entry(entry: &SitePermissionEntry) -> Self { + pub(crate) fn from_entry(entry: &SitePermissionEntry) -> Self { Self { profile_id: entry.profile_id().as_str().to_string(), origin: entry.origin().as_str().to_string(), @@ -433,7 +433,7 @@ pub(crate) struct HistorySyncRecord { } impl HistorySyncRecord { - fn from_entry(entry: &HistoryEntry, space_name: Option) -> Self { + pub(crate) fn from_entry(entry: &HistoryEntry, space_name: Option) -> Self { Self { profile_id: entry.profile_id().as_str().to_string(), space_id: entry.space_id().as_str().to_string(), @@ -457,7 +457,7 @@ pub(crate) struct PluginSettingsSyncRecord { } impl PluginSettingsSyncRecord { - fn from_plugin(plugin: &InstalledPlugin) -> Self { + pub(crate) fn from_plugin(plugin: &InstalledPlugin) -> Self { Self { plugin_id: plugin.id().as_str().to_string(), checksum: plugin.manifest().checksum().to_string(), diff --git a/crates/ely_browser_core/tests/local_state.rs b/crates/ely_browser_core/tests/local_state.rs new file mode 100644 index 0000000..de4abf4 --- /dev/null +++ b/crates/ely_browser_core/tests/local_state.rs @@ -0,0 +1,76 @@ +use std::error::Error; + +use ely_browser_core::{BrowserCore, InitialBrowserConfig}; +use ely_domain::{SyncObjectKind, SyncObjectPolicy, UrlText}; + +fn standard_core() -> Result> { + Ok(BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?) +} + +#[test] +fn local_state_round_trips_across_a_restart() -> Result<(), Box> { + let mut before = standard_core()?; + before.open_tab(UrlText::parse("https://servo.org/")?); + before.bookmark_active_tab()?; + before.set_command_query(">new-space Research"); + before.submit_command()?; + let bytes = before.build_local_state_bytes()?; + + let mut after = standard_core()?; + after.apply_local_state_bytes(&bytes)?; + let snapshot = after.snapshot()?; + + assert!(snapshot.tabs.iter().any(|tab| tab.url().as_str() == "https://servo.org/")); + assert!(snapshot.bookmarks.iter().any(|entry| entry.url().as_str() == "https://servo.org/")); + assert!(snapshot.spaces.iter().any(|space| space.name() == "Research")); + Ok(()) +} + +#[test] +fn paused_cloud_sync_does_not_reduce_local_state() -> Result<(), Box> { + let mut core = standard_core()?; + core.open_tab(UrlText::parse("https://example.com/paused")?); + core.set_sync_object_policy(SyncObjectKind::Tabs, SyncObjectPolicy::Paused); + + let bytes = core.build_local_state_bytes()?; + let mut restored = standard_core()?; + restored.apply_local_state_bytes(&bytes)?; + + assert!( + restored + .snapshot()? + .tabs + .iter() + .any(|tab| tab.url().as_str() == "https://example.com/paused"), + "tabs must persist locally even when cloud sync is paused" + ); + Ok(()) +} + +#[test] +fn private_profile_data_never_persists() -> Result<(), Box> { + let mut core = standard_core()?; + core.set_command_query(">new-private-profile Vault"); + core.submit_command()?; + core.set_command_query(">switch-profile Vault"); + core.submit_command()?; + core.open_tab(UrlText::parse("https://example.com/secret")?); + + let bytes = core.build_local_state_bytes()?; + let document = String::from_utf8(bytes.clone())?; + assert!(!document.contains("Vault"), "private profile must not persist"); + assert!(!document.contains("example.com/secret"), "private tab must not persist"); + + let mut restored = standard_core()?; + restored.apply_local_state_bytes(&bytes)?; + Ok(()) +} + +#[test] +fn unknown_local_state_revisions_fail_closed() -> Result<(), Box> { + let mut core = standard_core()?; + let result = + core.apply_local_state_bytes(br#"{"local_rev":99,"body":{"schema_rev":1,"bookmarks":[]}}"#); + assert!(result.is_err(), "unknown local_rev must be rejected"); + Ok(()) +}