diff --git a/crates/ely_browser_core/src/lib.rs b/crates/ely_browser_core/src/lib.rs index a8fbdcd..0c473cb 100644 --- a/crates/ely_browser_core/src/lib.rs +++ b/crates/ely_browser_core/src/lib.rs @@ -5,5 +5,5 @@ mod state; pub use error::CoreError; pub use state::{ BrowserCore, BrowserSnapshot, InitialBrowserConfig, InstalledPlugin, PluginAuditAction, - PluginAuditEvent, TrashedSpace, + PluginAuditEvent, SiteDataClearance, TrashedSpace, }; diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index 08de97c..b2bf2d1 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -19,6 +19,7 @@ mod notes; mod plugins; mod profiles; mod reading_list; +mod site_data; mod site_permissions; mod spaces; mod splits; @@ -30,6 +31,7 @@ mod tab_order; mod tabs; pub use plugins::{InstalledPlugin, PluginAuditAction, PluginAuditEvent}; +pub use site_data::SiteDataClearance; pub use spaces::TrashedSpace; #[derive(Clone, Debug)] diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index df0d386..a0e535c 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -285,6 +285,12 @@ impl BrowserCore { self.open_tab(url); Ok(true) } + "clear-site-data" + | "clear site data" + | "clear-site-data-for-this-profile" + | "clear site data for this profile" => { + Ok(self.clear_active_profile_site_data()?.is_some()) + } "about" | "open-about" | "open about" => { self.open_tab(about_url()?); Ok(true) diff --git a/crates/ely_browser_core/src/state/site_data.rs b/crates/ely_browser_core/src/state/site_data.rs new file mode 100644 index 0000000..1ecdfc6 --- /dev/null +++ b/crates/ely_browser_core/src/state/site_data.rs @@ -0,0 +1,102 @@ +use ely_domain::{HistoryEntry, ProfileId, SiteOrigin, SitePermissionAuditAction}; + +use crate::CoreError; + +use super::BrowserCore; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct SiteDataClearance { + history_entries: usize, + site_permissions: usize, +} + +impl SiteDataClearance { + #[must_use] + pub fn new(history_entries: usize, site_permissions: usize) -> Self { + Self { history_entries, site_permissions } + } + + #[must_use] + pub fn history_entries(self) -> usize { + self.history_entries + } + + #[must_use] + pub fn site_permissions(self) -> usize { + self.site_permissions + } + + #[must_use] + pub fn total_items(self) -> usize { + self.history_entries + self.site_permissions + } +} + +impl BrowserCore { + pub fn clear_active_profile_site_data( + &mut self, + ) -> Result, CoreError> { + let Some(origin) = SiteOrigin::from_url(self.active_tab()?.url())? else { + return Ok(None); + }; + let profile_id = self.active_profile_id.clone(); + let history_entries = self.clear_profile_history_for_origin(&profile_id, &origin)?; + let site_permissions = self.clear_site_permissions_for_origin(&profile_id, &origin)?; + + Ok(Some(SiteDataClearance::new(history_entries, site_permissions))) + } + + fn clear_profile_history_for_origin( + &mut self, + profile_id: &ProfileId, + origin: &SiteOrigin, + ) -> Result { + self.require_site_data_profile(profile_id)?; + let original_count = self.history_entries.len(); + self.history_entries.retain(|entry| { + entry.profile_id() != profile_id || !history_entry_matches_origin(entry, origin) + }); + Ok(original_count - self.history_entries.len()) + } + + fn clear_site_permissions_for_origin( + &mut self, + profile_id: &ProfileId, + origin: &SiteOrigin, + ) -> Result { + self.require_site_data_profile(profile_id)?; + let mut revoked_permissions = Vec::new(); + + self.site_permissions.retain(|entry| { + if entry.profile_id() == profile_id && entry.origin() == origin { + revoked_permissions.push((entry.origin().clone(), entry.feature())); + false + } else { + true + } + }); + + let revoked_count = revoked_permissions.len(); + for (origin, feature) in revoked_permissions { + self.record_site_permission_audit_event( + profile_id.clone(), + origin, + feature, + SitePermissionAuditAction::Revoked, + ); + } + Ok(revoked_count) + } + + fn require_site_data_profile(&self, profile_id: &ProfileId) -> Result<(), CoreError> { + if self.profiles.iter().any(|profile| profile.id() == profile_id) { + return Ok(()); + } + + Err(CoreError::ProfileNotFound { id: profile_id.clone() }) + } +} + +fn history_entry_matches_origin(entry: &HistoryEntry, origin: &SiteOrigin) -> bool { + SiteOrigin::from_url(entry.url()).ok().flatten().as_ref() == Some(origin) +} diff --git a/crates/ely_browser_core/src/state/site_permissions.rs b/crates/ely_browser_core/src/state/site_permissions.rs index 084ff2e..66c2566 100644 --- a/crates/ely_browser_core/src/state/site_permissions.rs +++ b/crates/ely_browser_core/src/state/site_permissions.rs @@ -174,7 +174,7 @@ impl BrowserCore { }) } - fn record_site_permission_audit_event( + pub(super) fn record_site_permission_audit_event( &mut self, profile_id: ProfileId, origin: SiteOrigin, diff --git a/crates/ely_browser_core/tests/site_data.rs b/crates/ely_browser_core/tests/site_data.rs new file mode 100644 index 0000000..0c3a2b6 --- /dev/null +++ b/crates/ely_browser_core/tests/site_data.rs @@ -0,0 +1,109 @@ +use std::error::Error; + +use ely_browser_core::{BrowserCore, InitialBrowserConfig}; +use ely_domain::{ + CommandIntent, ProfileKind, SiteOrigin, SitePermissionDecision, SitePermissionFeature, UrlText, +}; + +#[test] +fn clear_site_data_command_scopes_to_active_origin_and_profile() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let default_profile_id = core.snapshot()?.active_profile_id; + let work_space_id = core.snapshot()?.active_space_id; + let example_origin = SiteOrigin::parse("https://example.com")?; + let other_origin = SiteOrigin::parse("https://example.org")?; + + let example_tab_id = core.open_tab(UrlText::parse("https://example.com/work")?); + core.open_tab(UrlText::parse("https://example.org/work")?); + core.create_space("Research", "R", 0xf54e00)?; + core.open_tab(UrlText::parse("https://example.com/research")?); + core.set_site_permission( + example_origin.clone(), + SitePermissionFeature::Camera, + SitePermissionDecision::AllowAlways, + )?; + core.set_site_permission( + other_origin, + SitePermissionFeature::Notifications, + SitePermissionDecision::DenyAlways, + )?; + + let personal_profile_id = core.create_profile("Personal", 0xf54e00, ProfileKind::Standard)?; + core.select_profile(&personal_profile_id)?; + core.open_tab(UrlText::parse("https://example.com/personal")?); + core.set_site_permission( + example_origin, + SitePermissionFeature::Microphone, + SitePermissionDecision::DenyAlways, + )?; + + core.select_profile(&default_profile_id)?; + core.select_space(&work_space_id)?; + core.select_tab(&example_tab_id)?; + core.set_command_query(">clear-site-data-for-this-profile"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + + assert_eq!( + intent, + Some(CommandIntent::Command("clear-site-data-for-this-profile".to_string())) + ); + assert_eq!(snapshot.command_query, ""); + assert_eq!(snapshot.history_entries.len(), 1); + assert_eq!(snapshot.active_profile_history_entry_count, 1); + assert_eq!(snapshot.history_entries[0].url().as_str(), "https://example.org/work"); + assert_eq!(snapshot.site_permissions.len(), 1); + assert_eq!(snapshot.site_permissions[0].origin().as_str(), "https://example.org"); + assert_eq!(snapshot.site_permission_audit_events.len(), 3); + + core.select_profile(&personal_profile_id)?; + let personal_snapshot = core.snapshot()?; + assert_eq!(personal_snapshot.active_profile_history_entry_count, 1); + assert_eq!(personal_snapshot.site_permissions.len(), 1); + assert_eq!(personal_snapshot.site_permissions[0].origin().as_str(), "https://example.com"); + Ok(()) +} + +#[test] +fn clear_site_data_command_preserves_query_for_internal_page() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_command_query(">clear-site-data-for-this-profile"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + + assert_eq!( + intent, + Some(CommandIntent::Command("clear-site-data-for-this-profile".to_string())) + ); + assert_eq!(snapshot.command_query, ">clear-site-data-for-this-profile"); + assert!(snapshot.history_entries.is_empty()); + assert!(snapshot.site_permissions.is_empty()); + Ok(()) +} + +#[test] +fn clear_active_profile_site_data_reports_removed_counts() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let origin = SiteOrigin::parse("https://example.com")?; + + core.open_tab(UrlText::parse("https://example.com/account")?); + core.set_site_permission( + origin.clone(), + SitePermissionFeature::Camera, + SitePermissionDecision::AllowAlways, + )?; + core.set_site_permission( + origin, + SitePermissionFeature::Notifications, + SitePermissionDecision::DenyAlways, + )?; + + let clearance = + core.clear_active_profile_site_data()?.ok_or("missing active site data clearance")?; + + assert_eq!(clearance.history_entries(), 1); + assert_eq!(clearance.site_permissions(), 2); + assert_eq!(clearance.total_items(), 3); + Ok(()) +}